From e2b97b5d4b681365aa3f9f390f0a8a23d488c1b5 Mon Sep 17 00:00:00 2001 From: amrit Date: Sat, 26 Jul 2025 21:06:41 +0530 Subject: [PATCH 01/48] perf(server): parallelize and batch mail sync (#1788) Co-authored-by: Adam <13007539+MrgSub@users.noreply.github.com> --- apps/server/src/routes/agent/index.ts | 77 +++++++++++++++++++++------ 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index f21b29e858..a9593f60e7 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -473,19 +473,21 @@ export class ZeroDriver extends AIChatAgent { } async syncThreads(folder: string) { + const startTime = Date.now(); if (!this.driver) { console.error('No driver available for syncThreads'); throw new Error('No driver available'); } if (this.foldersInSync.has(folder)) { - console.log('Sync already in progress, skipping...'); + console.log(`Sync already in progress for ${folder}, skipping...`); return { synced: 0, message: 'Sync already in progress' }; } const threadCount = await this.getThreadCount(); + console.log(`Thread count for ${folder}: ${threadCount} (max: ${maxCount}, loop: ${shouldLoop})`); if (threadCount >= maxCount && !shouldLoop) { - console.log('Threads already synced, skipping...'); + console.log(`Threads already synced for ${folder}, skipping...`); return { synced: 0, message: 'Threads already synced' }; } @@ -493,14 +495,55 @@ export class ZeroDriver extends AIChatAgent { const self = this; - const syncSingleThread = (threadId: string) => + const fetchThread = (threadId: string) => Effect.gen(function* () { - yield* Effect.sleep(500); // Rate limiting delay - return yield* withRetry(Effect.tryPromise(() => self.syncThread({ threadId }))); + + yield* Effect.sleep(200); + + const threadData = yield* Effect.tryPromise(() => self.getWithRetry(threadId)); + + if (!threadData || !threadData.latest || !threadData.latest.threadId) { + return 0 as const; + } + const latest = threadData.latest!; + const id = latest.threadId as string; + + const serialized = JSON.stringify(threadData); + yield* Effect.tryPromise(() => + env.THREADS_BUCKET.put(self.getThreadKey(id), serialized, { + customMetadata: { threadId: id }, + }), + ); + + const normalizedReceivedOn = new Date(latest.receivedOn).toISOString(); + yield* Effect.try(() => + self.sql` + INSERT OR REPLACE INTO threads ( + id, thread_id, provider_id, latest_sender, + latest_received_on, latest_subject, latest_label_ids, updated_at + ) VALUES ( + ${id}, + ${id}, + 'google', + ${JSON.stringify(latest.sender)}, + ${normalizedReceivedOn}, + ${latest.subject}, + ${JSON.stringify(latest.tags.map((tag) => tag.id))}, + CURRENT_TIMESTAMP + ) + `, + ); + + self.agent?.broadcastChatMessage({ + type: OutgoingMessageType.Mail_Get, + threadId: id, + }); + + return 1 as const; }).pipe( Effect.catchAll((error) => { - console.error(`Failed to sync thread ${threadId}:`, error); - return Effect.succeed(null); + console.error(`Failed to process thread ${threadId} in ${folder}:`, error); + return Effect.succeed(0 as const); }), ); @@ -509,13 +552,9 @@ export class ZeroDriver extends AIChatAgent { let totalSynced = 0; let pageToken: string | null = null; let hasMore = true; - // let _pageCount = 0; while (hasMore) { - // _pageCount++; - - // Rate limiting delay between pages - yield* Effect.sleep(2000); + yield* Effect.sleep(1500); const result: IGetThreadsResponse = yield* Effect.tryPromise(() => self.listWithRetry({ @@ -525,13 +564,15 @@ export class ZeroDriver extends AIChatAgent { }), ); - // Process threads with controlled concurrency to avoid rate limits const threadIds = result.threads.map((thread) => thread.id); - const syncEffects = threadIds.map(syncSingleThread); - yield* Effect.all(syncEffects, { concurrency: 1, discard: true }); + const processedCounts = yield* Effect.all(threadIds.map(fetchThread), { + concurrency: 3, + }); + + const batchSynced = processedCounts.filter((c) => c === 1).length; + totalSynced += batchSynced; - totalSynced += result.threads.length; pageToken = result.nextPageToken; hasMore = pageToken !== null && shouldLoop; } @@ -555,8 +596,12 @@ export class ZeroDriver extends AIChatAgent { ), ), ); + const duration = Date.now() - startTime; + console.log(`[TIMING] syncThreads(${folder}) completed in ${duration}ms`); return result; } catch (error) { + const duration = Date.now() - startTime; + console.log(`[TIMING] syncThreads(${folder}) errored after ${duration}ms`); console.error('Failed to sync inbox threads:', error); throw error; } From 8ad891d2b611dcd3a0c589b467068487d2032ea8 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sat, 26 Jul 2025 13:39:19 -0700 Subject: [PATCH 02/48] Add user topics generation and label management (#1837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added automatic user topics generation from email subjects and improved label management to keep labels in sync with user interests. - **New Features** - Generates 1–6 user topics based on email subjects and creates labels for them if needed. - Stores and caches generated topics for faster access. - Exposes user topics via agent API and broadcasts updates to the frontend. - **Refactors** - Centralized Google service account parsing. - Improved thread and label change handling in pipelines for better reliability. ## Summary by CodeRabbit * **New Features** * Introduced user topic generation from email subjects to highlight user interests. * Enabled retrieval, display, and automatic creation of labels for user topics with cache management. * Added real-time notifications for user topic updates. * Added manual mail folder reload capability. * **Enhancements** * Improved label synchronization with detailed tracking of label changes per thread. * Updated email thread labeling instructions for clarity and flexibility. * Unified service account handling across workflows. * **Bug Fixes** * Fixed cache invalidation for user topics to ensure up-to-date information. --- apps/mail/components/party.tsx | 5 + apps/server/package.json | 1 + apps/server/src/lib/analyze/interests.ts | 107 +++++++ apps/server/src/lib/brain.fallback.prompts.ts | 13 +- .../factories/google-subscription.factory.ts | 47 ++- apps/server/src/pipelines.effect.ts | 271 ++++++++++-------- apps/server/src/routes/agent/index.ts | 94 +++++- apps/server/src/routes/agent/rpc.ts | 22 +- apps/server/src/routes/agent/types.ts | 4 + pnpm-lock.yaml | 15 + 10 files changed, 415 insertions(+), 164 deletions(-) create mode 100644 apps/server/src/lib/analyze/interests.ts diff --git a/apps/mail/components/party.tsx b/apps/mail/components/party.tsx index a297cf8b5f..d53b1211b2 100644 --- a/apps/mail/components/party.tsx +++ b/apps/mail/components/party.tsx @@ -14,6 +14,7 @@ export enum IncomingMessageType { ChatRequestCancel = 'cf_agent_chat_request_cancel', Mail_List = 'zero_mail_list_threads', Mail_Get = 'zero_mail_get_thread', + User_Topics = 'zero_user_topics', } export enum OutgoingMessageType { @@ -54,6 +55,10 @@ export const NotificationProvider = () => { q: searchValue.value, }), }); + } else if (type === IncomingMessageType.User_Topics) { + queryClient.invalidateQueries({ + queryKey: trpc.labels.list.queryKey(), + }); } } catch (error) { console.error('error parsing party message', error); diff --git a/apps/server/package.json b/apps/server/package.json index c48fd8490f..524fd77b7c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -74,6 +74,7 @@ "string-strip-html": "^13.4.12", "superjson": "catalog:", "twilio": "5.7.0", + "ulid": "3.0.1", "uuid": "11.1.0", "wrangler": "catalog:", "zod": "catalog:" diff --git a/apps/server/src/lib/analyze/interests.ts b/apps/server/src/lib/analyze/interests.ts new file mode 100644 index 0000000000..8d0fcb36b0 --- /dev/null +++ b/apps/server/src/lib/analyze/interests.ts @@ -0,0 +1,107 @@ +/* + * Licensed to Zero Email Inc. under one or more contributor license agreements. + * You may not use this file except in compliance with the Apache License, Version 2.0 (the "License"). + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Reuse or distribution of this file requires a license from Zero Email Inc. + */ + +import { generateObject } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; +import { env } from 'cloudflare:workers'; + +export interface GenerateTopicsOptions { + sampleSize?: number; + cacheTtlMin?: number; + existingLabels?: { name: string; id: string }[]; +} + +export interface UserTopic { + topic: string; + usecase: string; +} + +/** + * Generates 1-6 topics that represent what the user cares about based on their email subjects + */ +export async function generateWhatUserCaresAbout( + subjects: string[], + opts: GenerateTopicsOptions = {} +): Promise { + if (!subjects.length) { + return []; + } + + if (!env.OPENAI_API_KEY) { + console.warn('OPENAI_API_KEY not configured - topics generation disabled'); + return []; + } + + // Pre-process and normalize subjects + const cleaned = subjects + .map((s) => + s + .replace(/^(\s*(re|fwd):\s*)+/i, '') // strip reply/forward prefixes + .replace(/\s{2,}/g, ' ') + .trim() + ) + .filter(Boolean); + + if (!cleaned.length) { + return []; + } + + // Create frequency map and sample + const freq = new Map(); + cleaned.forEach((s) => freq.set(s, (freq.get(s) ?? 0) + 1)); + + // Sort by frequency and take sample that fits token budget + const SAMPLE_COUNT = opts.sampleSize ?? 250; // empirical: ~250 subjects ≈ 1.5k tokens + const sample = Array.from(freq.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, SAMPLE_COUNT) + .map(([s, n]) => `${n}× ${s}`); + + const schema = z.object({ + topics: z.array(z.object({ + topic: z.string().max(25), + usecase: z.string().max(100) + })).min(1).max(6), + }); + + const existingLabelsText = opts.existingLabels?.length + ? `\n\nExisting labels in this account (avoid duplicates or very similar topics):\n${opts.existingLabels.map(l => l.name).join(', ')}` + : ''; + + const systemPrompt = `You are an assistant that studies a person's email subjects and summarizes the *topics* they care about. +Return between 1 and 6 concise topic labels (≤5 words each) with a brief use case explanation for each topic (when someone would use/search for this topic).${existingLabelsText}`; + + const userPrompt = `Here are the email subjects (repetitions include a count prefix): + +${sample.join('\n')}`; + + try { + const { object } = await generateObject({ + model: openai(env.OPENAI_MODEL || 'gpt-4o-mini'), + schema, + system: systemPrompt, + prompt: userPrompt, + maxTokens: 150, + temperature: 0.2, + }); + + return object.topics; + } catch (error) { + console.error('Failed to generate user topics:', error); + return []; + } +} diff --git a/apps/server/src/lib/brain.fallback.prompts.ts b/apps/server/src/lib/brain.fallback.prompts.ts index 68f3eda7cd..ba63034a41 100644 --- a/apps/server/src/lib/brain.fallback.prompts.ts +++ b/apps/server/src/lib/brain.fallback.prompts.ts @@ -200,7 +200,7 @@ export const ThreadLabels = ( ) => dedent` You are a precise thread labeling agent. Your task is to analyze email thread summaries and assign relevant labels from a predefined set, ensuring accurate categorization while maintaining consistency. - Maintain absolute accuracy in labeling. Use only the predefined labels. Never generate new labels. Never include personal names. Always return labels in comma-separated format without spaces. + Maintain absolute accuracy in labeling. Use only the predefined labels. Never generate new labels. Never include personal names. Return labels in comma-separated format. Never say "Here is" or explain the process of labeling. @@ -208,16 +208,13 @@ export const ThreadLabels = ( - Use only the predefined set of labels - Return labels as comma-separated values without spaces + Choose up to 3 labels from the allowed_labels list only + Ignore any Gmail system labels (INBOX, UNREAD, CATEGORY_*, IMPORTANT) + Return labels exactly as written in allowed_labels, separated by commas Include company names as labels when heavily referenced Include bank names as labels when heavily referenced Do not use personal names as labels - Choose the single most relevant label for the thread - First consider if existing labels adequately categorize the thread - Only add new labels if existing labels are insufficient for proper categorization - Return existing labels plus any necessary new labels - + ${existingLabels.length > 0 diff --git a/apps/server/src/lib/factories/google-subscription.factory.ts b/apps/server/src/lib/factories/google-subscription.factory.ts index a7d041698b..d82efc8a00 100644 --- a/apps/server/src/lib/factories/google-subscription.factory.ts +++ b/apps/server/src/lib/factories/google-subscription.factory.ts @@ -17,6 +17,20 @@ interface IamPolicy { bindings?: { role: string; members: string[] }[]; } +export const getServiceAccount = (): GoogleServiceAccount => { + const serviceAccountJson = env.GOOGLE_S_ACCOUNT; + if (!serviceAccountJson || serviceAccountJson === '{}') { + throw new Error('GOOGLE_S_ACCOUNT environment variable is required'); + } + + try { + return JSON.parse(serviceAccountJson) as GoogleServiceAccount; + } catch (error) { + console.error('Invalid GOOGLE_S_ACCOUNT JSON format', serviceAccountJson, error); + throw new Error('Invalid GOOGLE_S_ACCOUNT JSON format'); + } +}; + class GoogleSubscriptionFactory extends BaseSubscriptionFactory { readonly providerId = EProviders.google; private accessToken: string | null = null; @@ -24,25 +38,6 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { private serviceAccount: GoogleServiceAccount | null = null; private pubsubServiceAccount: string = 'serviceAccount:gmail-api-push@system.gserviceaccount.com'; - private getServiceAccount(): GoogleServiceAccount { - if (!this.serviceAccount) { - const serviceAccountJson = env.GOOGLE_S_ACCOUNT; - if (!serviceAccountJson || serviceAccountJson === '{}') { - throw new Error('GOOGLE_S_ACCOUNT environment variable is required'); - } - - try { - this.serviceAccount = JSON.parse(serviceAccountJson); - } catch (error) { - console.log('Invalid GOOGLE_S_ACCOUNT JSON format', serviceAccountJson, error); - throw new Error('Invalid GOOGLE_S_ACCOUNT JSON format'); - } - return this.serviceAccount as GoogleServiceAccount; - } - - return this.serviceAccount; - } - private async getAccessToken(): Promise { const now = Math.floor(Date.now() / 1000); @@ -51,7 +46,11 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { return this.accessToken; } - const serviceAccount = this.getServiceAccount(); + if (!this.serviceAccount) { + this.serviceAccount = getServiceAccount(); + } + + const serviceAccount = this.serviceAccount; const payload = { iss: serviceAccount.client_email, @@ -107,7 +106,7 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { } private async setupPubSubTopic(topicName: string): Promise { - const serviceAccount = this.getServiceAccount(); + const serviceAccount = getServiceAccount(); const baseUrl = `https://pubsub.googleapis.com/v1/projects/${serviceAccount.project_id}`; const topicUrl = `${baseUrl}/topics/${topicName}`; @@ -134,7 +133,7 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { } private async setTopicIamPolicy(topicName: string): Promise { - const serviceAccount = this.getServiceAccount(); + const serviceAccount = getServiceAccount(); const baseUrl = `https://pubsub.googleapis.com/v1/projects/${serviceAccount.project_id}/topics/${topicName}`; // Get current policy @@ -167,7 +166,7 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { subscriptionName: string, pushEndpoint: string, ): Promise { - const serviceAccount = this.getServiceAccount(); + const serviceAccount = getServiceAccount(); const url = `https://pubsub.googleapis.com/v1/projects/${serviceAccount.project_id}/subscriptions/${subscriptionName}`; const requestBody = { @@ -218,7 +217,7 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { // Setup Gmail watch using direct API call instead of heavy googleapis package const accessToken = credentials.access_token || auth.credentials.access_token; - const serviceAccount = this.getServiceAccount(); + const serviceAccount = getServiceAccount(); console.log( `[SUBSCRIPTION] Setting up Gmail watch for connection: ${connectionData.id} ${topicName} projects/${serviceAccount.project_id}/topics/${topicName}`, diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index b32e75083c..0735e6b566 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -23,6 +23,7 @@ import { analyzeEmailIntent, } from './thread-workflow-utils'; import { defaultLabels, EPrompts, EProviders, type ParsedMessage, type Sender } from './types'; +import { getServiceAccount } from './lib/factories/google-subscription.factory'; import { EWorkflowType, getPromptName, runWorkflow } from './pipelines'; import { getZeroAgent } from './lib/server-utils'; import { type gmail_v1 } from '@googleapis/gmail'; @@ -85,8 +86,6 @@ const validateArguments = ( return connectionId; }); -const override = false; - /** * This function runs the main workflow. The main workflow is responsible for processing incoming messages from a Pub/Sub subscription and passing them to the appropriate pipeline. * It validates the subscription name and extracts the connection ID. @@ -101,19 +100,7 @@ export const runMainWorkflow = ( const { providerId, historyId } = params; - let serviceAccount = null; - if (override) { - serviceAccount = override; - } else { - if (!env.GOOGLE_S_ACCOUNT || env.GOOGLE_S_ACCOUNT === '{}') { - return yield* Effect.fail({ - _tag: 'MissingEnvironmentVariable' as const, - variable: 'GOOGLE_S_ACCOUNT', - }); - } - - serviceAccount = JSON.parse(env.GOOGLE_S_ACCOUNT); - } + const serviceAccount = getServiceAccount(); const connectionId = yield* validateArguments(params, serviceAccount); @@ -175,7 +162,8 @@ type ZeroWorkflowError = | { _tag: 'UnsupportedProvider'; providerId: string } | { _tag: 'DatabaseError'; error: unknown } | { _tag: 'GmailApiError'; error: unknown } - | { _tag: 'WorkflowCreationFailed'; error: unknown }; + | { _tag: 'WorkflowCreationFailed'; error: unknown } + | { _tag: 'LabelModificationFailed'; error: unknown; threadId: string }; export const runZeroWorkflow = ( params: ZeroWorkflowParams, @@ -274,37 +262,49 @@ export const runZeroWorkflow = ( return 'No history found'; } - // Extract thread IDs from history - const threadsChanged = new Set(); + // Extract thread IDs from history and track label changes const threadsAdded = new Set(); - history.forEach((historyItem) => { - if (historyItem.messagesAdded) { - historyItem.messagesAdded.forEach((messageAdded) => { - if (messageAdded.message?.threadId) { - // threadsChanged.add(messageAdded.message.threadId); - threadsAdded.add(messageAdded.message.threadId); - } - }); - } - if (historyItem.labelsAdded) { - historyItem.labelsAdded.forEach((labelAdded) => { - if (labelAdded.message?.threadId) { - // threadsChanged.add(labelAdded.message.threadId); - } - }); - } - if (historyItem.labelsRemoved) { - historyItem.labelsRemoved.forEach((labelRemoved) => { - if (labelRemoved.message?.threadId) { - // threadsChanged.add(labelRemoved.message.threadId); - } - }); + const threadLabelChanges = new Map< + string, + { addLabels: Set; removeLabels: Set } + >(); + + // Optimal single-pass functional processing + const processLabelChange = ( + labelChange: { message?: gmail_v1.Schema$Message; labelIds?: string[] | null }, + isAddition: boolean, + ) => { + const threadId = labelChange.message?.threadId; + if (!threadId || !labelChange.labelIds?.length) return; + + let changes = threadLabelChanges.get(threadId); + if (!changes) { + changes = { addLabels: new Set(), removeLabels: new Set() }; + threadLabelChanges.set(threadId, changes); } + + const targetSet = isAddition ? changes.addLabels : changes.removeLabels; + labelChange.labelIds.forEach((labelId) => targetSet.add(labelId)); + }; + + history.forEach((historyItem) => { + // Extract thread IDs from messages + historyItem.messagesAdded?.forEach((msg) => { + if (msg.message?.threadId) { + threadsAdded.add(msg.message.threadId); + } + }); + + // Process label changes using shared helper + historyItem.labelsAdded?.forEach((labelAdded) => processLabelChange(labelAdded, true)); + historyItem.labelsRemoved?.forEach((labelRemoved) => + processLabelChange(labelRemoved, false), + ); }); yield* Console.log( '[ZERO_WORKFLOW] Found unique thread IDs:', - Array.from(threadsChanged), + Array.from(threadLabelChanges.keys()), Array.from(threadsAdded), ); @@ -346,61 +346,97 @@ export const runZeroWorkflow = ( } yield* Console.log('[ZERO_WORKFLOW] Synced threads:', syncResults); - } - - // Process all threads concurrently using Effect.all - if (threadsChanged.size > 0) { - const threadWorkflowParams = Array.from(threadsChanged).map((threadId) => ({ - connectionId, - threadId, - providerId: foundConnection.providerId, - })); - - const threadResults = yield* Effect.all( - threadWorkflowParams.map((params) => - Effect.gen(function* () { - // Check if thread is already processing - const isProcessing = yield* Effect.tryPromise({ - try: () => env.gmail_processing_threads.get(params.threadId.toString()), - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }); - if (isProcessing === 'true') { - yield* Console.log('[ZERO_WORKFLOW] Thread already processing:', params.threadId); - return 'Thread already processing'; - } + // Run thread workflow for each successfully synced thread + if (syncedCount > 0) { + yield* Effect.tryPromise({ + try: () => agent.reloadFolder('inbox'), + catch: (error) => ({ _tag: 'GmailApiError' as const, error }), + }).pipe( + Effect.tap(() => Console.log('[ZERO_WORKFLOW] Successfully reloaded inbox folder')), + Effect.orElse(() => + Effect.gen(function* () { + yield* Console.log('[ZERO_WORKFLOW] Failed to reload inbox folder'); + return undefined; + }), + ), + ); - // Set processing flag for thread - yield* Effect.tryPromise({ - try: () => { - console.log( - '[ZERO_WORKFLOW] Setting processing flag for thread:', - params.threadId, - ); - return env.gmail_processing_threads.put(params.threadId.toString(), 'true', { - expirationTtl: 1800, - }); - }, - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }); + yield* Console.log( + `[ZERO_WORKFLOW] Running thread workflows for ${syncedCount} synced threads`, + ); - // Run the thread workflow - return yield* runWorkflow(EWorkflowType.THREAD, params).pipe( - Effect.mapError( - (error): ZeroWorkflowError => ({ - _tag: 'WorkflowCreationFailed' as const, + const threadWorkflowResults = yield* Effect.allSuccesses( + syncResults.map(({ threadId }) => + runWorkflow(EWorkflowType.THREAD, { + connectionId, + threadId, + providerId: foundConnection.providerId, + }).pipe( + Effect.tap(() => + Console.log(`[ZERO_WORKFLOW] Successfully ran thread workflow for ${threadId}`), + ), + Effect.tapError((error) => + Console.log( + `[ZERO_WORKFLOW] Failed to run thread workflow for ${threadId}:`, error, - }), + ), ), - ); - }), - ), - { concurrency: 1, discard: true }, // Process up to 5 threads concurrently + ), + ), + { concurrency: 1 }, // Limit concurrency to avoid overwhelming the system + ); + + const threadWorkflowSuccessCount = threadWorkflowResults.length; + const threadWorkflowFailedCount = syncedCount - threadWorkflowSuccessCount; + + if (threadWorkflowFailedCount > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Warning: ${threadWorkflowFailedCount}/${syncedCount} thread workflows failed. Successfully processed: ${threadWorkflowSuccessCount}`, + ); + } else { + yield* Console.log( + `[ZERO_WORKFLOW] Successfully ran all ${threadWorkflowSuccessCount} thread workflows`, + ); + } + } + } + + // Process label changes for threads + if (threadLabelChanges.size > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Processing label changes for ${threadLabelChanges.size} threads`, ); - yield* Console.log('[ZERO_WORKFLOW] All thread workflows completed:', threadResults); + // Process each thread's label changes + for (const [threadId, changes] of threadLabelChanges) { + const addLabels = Array.from(changes.addLabels); + const removeLabels = Array.from(changes.removeLabels); + + // Only call if there are actual changes to make + if (addLabels.length > 0 || removeLabels.length > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Modifying labels for thread ${threadId}: +${addLabels.length} -${removeLabels.length}`, + ); + yield* Effect.tryPromise({ + try: () => agent.modifyThreadLabelsInDB(threadId, addLabels, removeLabels), + catch: (error) => ({ _tag: 'LabelModificationFailed' as const, error, threadId }), + }).pipe( + Effect.orElse(() => + Effect.gen(function* () { + yield* Console.log( + `[ZERO_WORKFLOW] Failed to modify labels for thread ${threadId}`, + ); + return undefined; + }), + ), + ); + } + } + + yield* Console.log('[ZERO_WORKFLOW] Completed label modifications'); } else { - yield* Console.log('[ZERO_WORKFLOW] No threads to process'); + yield* Console.log('[ZERO_WORKFLOW] No threads with label changes to process'); } // Clean up processing flag @@ -802,45 +838,36 @@ export const runThreadWorkflow = ( const userLabels = yield* Effect.tryPromise({ try: async () => { - console.log('[THREAD_WORKFLOW] Getting user labels for connection:', connectionId); + console.log('[THREAD_WORKFLOW] Getting user topics for connection:', connectionId); let userLabels: { name: string; usecase: string }[] = []; - const connectionLabels = await env.connection_labels.get(connectionId.toString()); - if (connectionLabels) { - try { - console.log('[THREAD_WORKFLOW] Parsing existing connection labels'); - const parsed = JSON.parse(connectionLabels); - if ( - Array.isArray(parsed) && - parsed.every((label) => typeof label === 'object' && label.name && label.usecase) - ) { - userLabels = parsed; - } else { - throw new Error('Invalid label format'); - } - } catch { - console.log('[THREAD_WORKFLOW] Failed to parse labels, using defaults'); - await env.connection_labels.put( - connectionId.toString(), - JSON.stringify(defaultLabels), - ); + try { + const userTopics = await agent.getUserTopics(); + if (userTopics.length > 0) { + userLabels = userTopics.map((topic) => ({ + name: topic.topic, + usecase: topic.usecase, + })); + console.log('[THREAD_WORKFLOW] Using user topics as labels:', userLabels); + } else { + console.log('[THREAD_WORKFLOW] No user topics found, using defaults'); userLabels = defaultLabels; } - } else { - console.log('[THREAD_WORKFLOW] No labels found, using defaults'); - await env.connection_labels.put( - connectionId.toString(), - JSON.stringify(defaultLabels), - ); + } catch (error) { + console.log('[THREAD_WORKFLOW] Failed to get user topics, using defaults:', error); userLabels = defaultLabels; } - return userLabels.length ? userLabels : defaultLabels; + return userLabels; }, catch: (error) => ({ _tag: 'DatabaseError' as const, error }), }); const generatedLabels = yield* Effect.tryPromise({ try: async () => { - console.log('[THREAD_WORKFLOW] Generating labels for thread:', threadId); + console.log('[THREAD_WORKFLOW] Generating labels for thread:', { + userLabels, + threadId, + threadLabels: thread.labels, + }); const labelsResponse: any = await env.AI.run( '@cf/meta/llama-3.3-70b-instruct-fp8-fast', { @@ -899,12 +926,12 @@ export const runThreadWorkflow = ( labelsToAdd, labelsToRemove, ); - await agent.modifyLabels( - [threadId.toString()], - labelsToAdd, - labelsToRemove, - true, - ); + // await agent.modifyLabels( + // [threadId.toString()], + // labelsToAdd, + // labelsToRemove, + // true, + // ); // await agent.syncThread({ threadId: threadId.toString() }); console.log('[THREAD_WORKFLOW] Successfully modified thread labels'); } else { diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index a9593f60e7..62a77c42fb 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -34,6 +34,7 @@ import { type ParsedMessage, } from '../../types'; import type { IGetThreadResponse, IGetThreadsResponse, MailManager } from '../../lib/driver/types'; +import { generateWhatUserCaresAbout, type UserTopic } from '../../lib/analyze/interests'; import { DurableObjectOAuthClientProvider } from 'agents/mcp/do-oauth-client-provider'; import { AiChatPrompt, GmailSearchAssistantSystemPrompt } from '../../lib/prompts'; import { connectionToDriver, getZeroSocketAgent } from '../../lib/server-utils'; @@ -86,6 +87,78 @@ export class ZeroDriver extends AIChatAgent { `; } + getAllSubjects() { + const subjects = this.sql` + SELECT latest_subject FROM threads + WHERE EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = 'INBOX' + ); + `; + return subjects.map((row) => row.latest_subject) as string[]; + } + + async getUserTopics(): Promise { + // Check storage first + // await this.ctx.storage.delete('user_topics'); + const stored = await this.ctx.storage.get('user_topics'); + if (stored) { + try { + const { topics, timestamp } = stored as { topics: UserTopic[]; timestamp: number }; + const cacheAge = Date.now() - timestamp; + const ttl = 24 * 60 * 60 * 1000; // 24 hours + + if (cacheAge < ttl) { + return topics; + } + } catch { + // Invalid stored data, continue to regenerate + } + } + + // Generate new topics + const subjects = this.getAllSubjects(); + let existingLabels: { name: string; id: string }[] = []; + + try { + existingLabels = await this.getUserLabels(); + } catch (error) { + console.warn('Failed to get existing labels for topic generation:', error); + } + + const topics = await generateWhatUserCaresAbout(subjects, { existingLabels }); + + if (topics.length > 0) { + // Ensure labels exist in user account + try { + const existingLabelNames = new Set(existingLabels.map((label) => label.name.toLowerCase())); + + for (const topic of topics) { + const topicName = topic.topic.toLowerCase(); + if (!existingLabelNames.has(topicName)) { + console.log(`Creating label for topic: ${topic.topic}`); + await this.createLabel({ + name: topic.topic, + }); + } + } + } catch (error) { + console.error('Failed to ensure topic labels exist:', error); + } + + // Store the result + await this.ctx.storage.put('user_topics', { + topics, + timestamp: Date.now(), + }); + + await this.agent?.broadcastChatMessage({ + type: OutgoingMessageType.User_Topics, + }); + } + + return topics; + } + async setMetaData(connectionId: string) { await this.setName(connectionId); this.agent = await getZeroSocketAgent(connectionId); @@ -378,6 +451,13 @@ export class ZeroDriver extends AIChatAgent { }); } + async reloadFolder(folder: string) { + this.agent?.broadcastChatMessage({ + type: OutgoingMessageType.Mail_List, + folder, + }); + } + async syncThread({ threadId }: { threadId: string }) { if (this.name === 'general') return; if (!this.driver) { @@ -473,7 +553,7 @@ export class ZeroDriver extends AIChatAgent { } async syncThreads(folder: string) { - const startTime = Date.now(); + const startTime = Date.now(); if (!this.driver) { console.error('No driver available for syncThreads'); throw new Error('No driver available'); @@ -485,7 +565,9 @@ export class ZeroDriver extends AIChatAgent { } const threadCount = await this.getThreadCount(); - console.log(`Thread count for ${folder}: ${threadCount} (max: ${maxCount}, loop: ${shouldLoop})`); + console.log( + `Thread count for ${folder}: ${threadCount} (max: ${maxCount}, loop: ${shouldLoop})`, + ); if (threadCount >= maxCount && !shouldLoop) { console.log(`Threads already synced for ${folder}, skipping...`); return { synced: 0, message: 'Threads already synced' }; @@ -497,9 +579,8 @@ export class ZeroDriver extends AIChatAgent { const fetchThread = (threadId: string) => Effect.gen(function* () { - yield* Effect.sleep(200); - + const threadData = yield* Effect.tryPromise(() => self.getWithRetry(threadId)); if (!threadData || !threadData.latest || !threadData.latest.threadId) { @@ -516,8 +597,9 @@ export class ZeroDriver extends AIChatAgent { ); const normalizedReceivedOn = new Date(latest.receivedOn).toISOString(); - yield* Effect.try(() => - self.sql` + yield* Effect.try( + () => + self.sql` INSERT OR REPLACE INTO threads ( id, thread_id, provider_id, latest_sender, latest_received_on, latest_subject, latest_label_ids, updated_at diff --git a/apps/server/src/routes/agent/rpc.ts b/apps/server/src/routes/agent/rpc.ts index aa77790a8c..b5f4553453 100644 --- a/apps/server/src/routes/agent/rpc.ts +++ b/apps/server/src/routes/agent/rpc.ts @@ -18,6 +18,8 @@ import type { IOutgoingMessage } from '../../types'; import { RpcTarget } from 'cloudflare:workers'; import { ZeroDriver } from '.'; +const shouldReSyncThreadsAfterActions = false; + export class DriverRpcDO extends RpcTarget { constructor( private mainDo: ZeroDriver, @@ -41,6 +43,10 @@ export class DriverRpcDO extends RpcTarget { return await this.mainDo.createLabel(label); } + async getUserTopics() { + return await this.mainDo.getUserTopics(); + } + async updateLabel( id: string, label: { name: string; color?: { backgroundColor: string; textColor: string } }, @@ -86,7 +92,8 @@ export class DriverRpcDO extends RpcTarget { async markThreadsRead(threadIds: string[]) { const result = await this.mainDo.markThreadsRead(threadIds); - await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); + if (shouldReSyncThreadsAfterActions) + await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } @@ -96,7 +103,8 @@ export class DriverRpcDO extends RpcTarget { async markThreadsUnread(threadIds: string[]) { const result = await this.mainDo.markThreadsUnread(threadIds); - await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); + if (shouldReSyncThreadsAfterActions) + await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } @@ -140,13 +148,15 @@ export class DriverRpcDO extends RpcTarget { async markAsRead(threadIds: string[]) { const result = await this.mainDo.markAsRead(threadIds); - await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); + if (shouldReSyncThreadsAfterActions) + await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } async markAsUnread(threadIds: string[]) { const result = await this.mainDo.markAsUnread(threadIds); - await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); + if (shouldReSyncThreadsAfterActions) + await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } @@ -208,6 +218,10 @@ export class DriverRpcDO extends RpcTarget { return this.mainDo.broadcast(message); } + async reloadFolder(folder: string) { + this.mainDo.reloadFolder(folder); + } + // async getThreadsFromDB(params: { // labelIds?: string[]; // folder?: string; diff --git a/apps/server/src/routes/agent/types.ts b/apps/server/src/routes/agent/types.ts index 68d71c2685..ce3dd33be6 100644 --- a/apps/server/src/routes/agent/types.ts +++ b/apps/server/src/routes/agent/types.ts @@ -15,6 +15,7 @@ export enum OutgoingMessageType { ChatClear = 'cf_agent_chat_clear', Mail_List = 'zero_mail_list_threads', Mail_Get = 'zero_mail_get_thread', + User_Topics = 'zero_user_topics', } export type IncomingMessage = @@ -68,6 +69,9 @@ export type OutgoingMessage = | { type: OutgoingMessageType.Mail_Get; threadId: string; + } + | { + type: OutgoingMessageType.User_Topics; }; export type QueueFunc = (name: string, payload: unknown) => Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 726448f2cf..b0d7a3c940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@trpc/server': specifier: ^11.1.4 version: 11.4.3 + '@trpc/tanstack-react-query': + specifier: ^11.1.4 + version: 11.4.3 autumn-js: specifier: ^0.0.48 version: 0.0.48 @@ -27,6 +30,9 @@ catalogs: react: specifier: ^19.1.0 version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0 superjson: specifier: ^2.2.2 version: 2.2.2 @@ -642,6 +648,9 @@ importers: twilio: specifier: 5.7.0 version: 5.7.0 + ulid: + specifier: 3.0.1 + version: 3.0.1 uuid: specifier: 11.1.0 version: 11.1.0 @@ -8393,6 +8402,10 @@ packages: resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} engines: {node: '>=18'} + ulid@3.0.1: + resolution: {integrity: sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -17296,6 +17309,8 @@ snapshots: uint8array-extras@1.4.0: {} + ulid@3.0.1: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 From 2015ef3ae55062103654f5af85e4148a5173fe1d Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 12:58:55 -0700 Subject: [PATCH 03/48] Add Effect language service and configure pretty logging (#1843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added the Effect language service for improved TypeScript support and set up pretty logging for better error and debug output. - **Dependencies** - Added @effect/language-service as a dev dependency and configured it in tsconfig. - **Refactors** - Updated logging to use Effect's pretty logger for clearer logs. --- apps/server/package.json | 1 + apps/server/src/pipelines.effect.ts | 14 +++++++++++--- apps/server/src/thread-workflow-utils/index.ts | 3 +-- apps/server/tsconfig.json | 16 +++++++++++++++- pnpm-lock.yaml | 9 +++++++++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 524fd77b7c..34982dc9b9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -80,6 +80,7 @@ "zod": "catalog:" }, "devDependencies": { + "@effect/language-service": "0.31.1", "@types/he": "1.2.3", "@types/node": "^22.9.0", "@types/react": "19.1.6", diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index 0735e6b566..a84f70c2f0 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -27,16 +27,16 @@ import { getServiceAccount } from './lib/factories/google-subscription.factory'; import { EWorkflowType, getPromptName, runWorkflow } from './pipelines'; import { getZeroAgent } from './lib/server-utils'; import { type gmail_v1 } from '@googleapis/gmail'; +import { Effect, Console, Logger } from 'effect'; import { env } from 'cloudflare:workers'; import { connection } from './db/schema'; -import { Effect, Console } from 'effect'; import * as cheerio from 'cheerio'; import { eq } from 'drizzle-orm'; import { createDb } from './db'; const showLogs = true; -export const log = (message: string, ...args: any[]) => { +const log = (message: string, ...args: any[]) => { if (showLogs) { console.log(message, ...args); return message; @@ -44,6 +44,9 @@ export const log = (message: string, ...args: any[]) => { return 'no message'; }; +// Configure pretty logger to stderr +export const loggerLayer = Logger.add(Logger.prettyLogger({ stderr: true })); + const isValidUUID = (str: string): boolean => { const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return regex.test(str); @@ -144,7 +147,10 @@ export const runMainWorkflow = ( yield* Console.log('[MAIN_WORKFLOW] Workflow completed successfully'); return 'Workflow completed successfully'; - }).pipe(Effect.tapError((error) => Console.log('[MAIN_WORKFLOW] Error in workflow:', error))); + }).pipe( + Effect.tapError((error) => Console.log('[MAIN_WORKFLOW] Error in workflow:', error)), + Effect.provide(loggerLayer), + ); // Define the ZeroWorkflow parameters type type ZeroWorkflowParams = { @@ -483,6 +489,7 @@ export const runZeroWorkflow = ( Effect.flatMap(() => Effect.fail(error)), ); }), + Effect.provide(loggerLayer), ); // Define the ThreadWorkflow parameters type @@ -1036,6 +1043,7 @@ export const runThreadWorkflow = ( Effect.flatMap(() => Effect.fail(error)), ); }), + Effect.provide(loggerLayer), ); // // Helper functions for vectorization and AI processing diff --git a/apps/server/src/thread-workflow-utils/index.ts b/apps/server/src/thread-workflow-utils/index.ts index 9f8e2f00d5..80c5d56a01 100644 --- a/apps/server/src/thread-workflow-utils/index.ts +++ b/apps/server/src/thread-workflow-utils/index.ts @@ -1,7 +1,6 @@ import type { IGetThreadResponse } from '../lib/driver/types'; import { composeEmail } from '../trpc/routes/ai/compose'; import { type ParsedMessage } from '../types'; -import { log } from '../pipelines.effect'; import { connection } from '../db/schema'; const shouldGenerateDraft = ( @@ -100,7 +99,7 @@ const generateAutomaticDraft = async ( return draftContent; } catch (error) { - log('[THREAD_WORKFLOW] Failed to generate automatic draft:', { + console.log('[THREAD_WORKFLOW] Failed to generate automatic draft:', { connectionId, error: error instanceof Error ? error.message : String(error), }); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 7b44315fc4..d68b6cdae7 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,4 +1,18 @@ { "extends": "@zero/tsconfig/base", - "include": ["src/**/*.ts", "src/overrides.d.ts", "worker-configuration.d.ts", "drizzle.config.ts", "tests/**/*.ts", "evals/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/overrides.d.ts", + "worker-configuration.d.ts", + "drizzle.config.ts", + "tests/**/*.ts", + "evals/**/*.ts" + ], + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service" + } + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d7a3c940..1815295b40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -661,6 +661,9 @@ importers: specifier: 'catalog:' version: 3.25.67 devDependencies: + '@effect/language-service': + specifier: 0.31.1 + version: 0.31.1 '@types/he': specifier: 1.2.3 version: 1.2.3 @@ -1079,6 +1082,10 @@ packages: '@dub/better-auth@0.0.3': resolution: {integrity: sha512-5haJGPt8Xab1L4De6naqEwC8k2KJrxI2iAfs5t9u6iNob3DNBTsbSZGb2godIigg9ZsS2J+joKKG5hDK6jT0UQ==} + '@effect/language-service@0.31.1': + resolution: {integrity: sha512-XnnM1crPsikx/qT5nSkOgmdXBsaucZPa3O5tO63mqqFxqiThkhKb8XTUCrixJmcbcAcGAxm4eUZUQVjXJnXf6g==} + hasBin: true + '@egjs/agent@2.4.4': resolution: {integrity: sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==} @@ -9255,6 +9262,8 @@ snapshots: dependencies: zod: 3.25.67 + '@effect/language-service@0.31.1': {} + '@egjs/agent@2.4.4': {} '@egjs/children-differ@1.0.1': From 724cbb8c2c99312ff43116d7914a873a60a72329 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Mon, 28 Jul 2025 02:28:51 +0530 Subject: [PATCH 04/48] fix: auto-focus reply input when reply is triggered (#1841) --- apps/mail/components/mail/reply-composer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index ea35fb4427..8d94e80562 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -257,7 +257,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { initialCc={ensureEmailArray(draft?.cc)} initialBcc={ensureEmailArray(draft?.bcc)} initialSubject={draft?.subject} - autofocus={false} + autofocus={true} settingsLoading={settingsLoading} replyingTo={replyToMessage?.sender.email} /> From 7cd0e407e06e8ff5a8d893d21d1f2bd4338e42c6 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 14:14:28 -0700 Subject: [PATCH 05/48] Add auth validation and logging for API endpoints (#1844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added authentication checks and logging to API endpoints to block unauthorized requests and help debug failed logins. - **Bug Fixes** - Log when missing or invalid auth headers are detected. - Return 401 responses for unauthorized access. --- apps/server/src/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3056ffa02d..8555d2a785 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -598,11 +598,13 @@ export default class extends WorkerEntrypoint { async (request, env, ctx) => { const authBearer = request.headers.get('Authorization'); if (!authBearer) { + console.log('No auth provided'); return new Response('Unauthorized', { status: 401 }); } const auth = createAuth(); const session = await auth.api.getMcpSession({ headers: request.headers }); if (!session) { + console.log('Invalid auth provided', Array.from(request.headers.entries())); return new Response('Unauthorized', { status: 401 }); } ctx.props = { @@ -632,6 +634,10 @@ export default class extends WorkerEntrypoint { } const auth = createAuth(); const session = await auth.api.getMcpSession({ headers: request.headers }); + if (!session) { + console.log('Invalid auth provided', Array.from(request.headers.entries())); + return new Response('Unauthorized', { status: 401 }); + } ctx.props = { userId: session?.userId, }; From 7cd7d856dc85170c5d8e8c24a85e8e828051d8e4 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:01:15 -0700 Subject: [PATCH 06/48] Optimize thread synchronization with rate limiting and concurrency control (#1845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Improved thread synchronization by adding rate limiting and stricter concurrency control to prevent API overload and duplicate syncs. - **Performance** - Added delays between thread and page syncs to respect rate limits. - Limited concurrent thread syncs to one at a time. ## Summary by CodeRabbit * **Refactor** * Improved the thread synchronization process for enhanced reliability and efficiency. * Introduced stricter sequential processing and increased rate limiting to prevent overload. * Simplified error handling and logging for clearer feedback during synchronization. No user-facing features or visible changes have been introduced. --- apps/server/src/routes/agent/index.ts | 987 ++++++++++++++++++++------ apps/server/src/routes/agent/rpc.ts | 4 +- 2 files changed, 770 insertions(+), 221 deletions(-) diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 62a77c42fb..6fce490b5c 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -55,7 +55,6 @@ import { openai } from '@ai-sdk/openai'; import { createDb } from '../../db'; import { DriverRpcDO } from './rpc'; import { eq } from 'drizzle-orm'; - import { Effect } from 'effect'; const decoder = new TextDecoder(); @@ -63,6 +62,222 @@ const decoder = new TextDecoder(); const shouldDropTables = false; const maxCount = 20; const shouldLoop = env.THREAD_SYNC_LOOP !== 'false'; + +// Error types for getUserTopics +export class StorageError extends Error { + readonly _tag = 'StorageError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'StorageError'; + this.cause = cause; + } +} + +export class LabelRetrievalError extends Error { + readonly _tag = 'LabelRetrievalError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'LabelRetrievalError'; + this.cause = cause; + } +} + +export class TopicGenerationError extends Error { + readonly _tag = 'TopicGenerationError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'TopicGenerationError'; + this.cause = cause; + } +} + +export class LabelCreationError extends Error { + readonly _tag = 'LabelCreationError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'LabelCreationError'; + this.cause = cause; + } +} + +export class BroadcastError extends Error { + readonly _tag = 'BroadcastError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'BroadcastError'; + this.cause = cause; + } +} + +// Error types for syncThread +export class ThreadSyncError extends Error { + readonly _tag = 'ThreadSyncError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'ThreadSyncError'; + this.cause = cause; + } +} + +export class DriverUnavailableError extends Error { + readonly _tag = 'DriverUnavailableError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'DriverUnavailableError'; + this.cause = cause; + } +} + +export class ThreadDataError extends Error { + readonly _tag = 'ThreadDataError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'ThreadDataError'; + this.cause = cause; + } +} + +export class DateNormalizationError extends Error { + readonly _tag = 'DateNormalizationError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'DateNormalizationError'; + this.cause = cause; + } +} + +// Error types for syncThreads +export class FolderSyncError extends Error { + readonly _tag = 'FolderSyncError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'FolderSyncError'; + this.cause = cause; + } +} + +export class ThreadListError extends Error { + readonly _tag = 'ThreadListError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'ThreadListError'; + this.cause = cause; + } +} + +export class ConcurrencyError extends Error { + readonly _tag = 'ConcurrencyError'; + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'ConcurrencyError'; + this.cause = cause; + } +} + +// Union type for all possible errors +export type TopicGenerationErrors = + | StorageError + | LabelRetrievalError + | TopicGenerationError + | LabelCreationError + | BroadcastError; + +export type ThreadSyncErrors = + | ThreadSyncError + | DriverUnavailableError + | ThreadDataError + | DateNormalizationError; + +export type FolderSyncErrors = + | FolderSyncError + | DriverUnavailableError + | ThreadListError + | ConcurrencyError; + +// Success cases and result types +export interface TopicGenerationResult { + topics: UserTopic[]; + cacheHit: boolean; + cacheAge?: number; + subjectsAnalyzed: number; + existingLabelsCount: number; + labelsCreated: number; + broadcastSent: boolean; +} + +export interface ThreadSyncResult { + success: boolean; + threadId: string; + threadData?: IGetThreadResponse; + reason?: string; + normalizedReceivedOn?: string; + broadcastSent: boolean; +} + +export interface FolderSyncResult { + synced: number; + message: string; + folder: string; + pagesProcessed: number; + totalThreads: number; + successfulSyncs: number; + failedSyncs: number; + broadcastSent: boolean; +} + +export interface CachedTopics { + topics: UserTopic[]; + timestamp: number; +} + +// Requirements interface +export interface TopicGenerationRequirements { + readonly storage: DurableObjectStorage; + readonly agent?: DurableObjectStub; + readonly connectionId: string; +} + +export interface ThreadSyncRequirements { + readonly driver: MailManager; + readonly agent?: DurableObjectStub; + readonly connectionId: string; +} + +export interface FolderSyncRequirements { + readonly driver: MailManager; + readonly agent?: DurableObjectStub; + readonly connectionId: string; +} + +// Constants +export const TOPIC_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours +export const TOPIC_CACHE_KEY = 'user_topics'; + +// Type aliases for better readability +export type TopicGenerationEffect = Effect.Effect< + TopicGenerationResult, + TopicGenerationErrors, + TopicGenerationRequirements +>; +export type TopicGenerationSuccess = TopicGenerationResult; +export type TopicGenerationFailure = TopicGenerationErrors; + +export type ThreadSyncEffect = Effect.Effect< + ThreadSyncResult, + ThreadSyncErrors, + ThreadSyncRequirements +>; +export type ThreadSyncSuccess = ThreadSyncResult; +export type ThreadSyncFailure = ThreadSyncErrors; + +export type FolderSyncEffect = Effect.Effect< + FolderSyncResult, + FolderSyncErrors, + FolderSyncRequirements +>; +export type FolderSyncSuccess = FolderSyncResult; +export type FolderSyncFailure = FolderSyncErrors; + export class ZeroDriver extends AIChatAgent { private foldersInSync: Map = new Map(); private syncThreadsInProgress: Map = new Map(); @@ -98,65 +313,220 @@ export class ZeroDriver extends AIChatAgent { } async getUserTopics(): Promise { - // Check storage first - // await this.ctx.storage.delete('user_topics'); - const stored = await this.ctx.storage.get('user_topics'); - if (stored) { - try { - const { topics, timestamp } = stored as { topics: UserTopic[]; timestamp: number }; - const cacheAge = Date.now() - timestamp; - const ttl = 24 * 60 * 60 * 1000; // 24 hours + const self = this; + + // Create the Effect with proper types - no external requirements needed + const topicGenerationEffect = Effect.gen(function* () { + console.log(`[getUserTopics] Starting topic generation for connection: ${self.name}`); + + const result: TopicGenerationResult = { + topics: [], + cacheHit: false, + subjectsAnalyzed: 0, + existingLabelsCount: 0, + labelsCreated: 0, + broadcastSent: false, + }; + + // Check storage first + const stored = yield* Effect.tryPromise(() => self.ctx.storage.get(TOPIC_CACHE_KEY)).pipe( + Effect.tap(() => + Effect.sync(() => console.log(`[getUserTopics] Checking storage for cached topics`)), + ), + Effect.catchAll((error) => { + console.warn(`[getUserTopics] Failed to get cached topics from storage:`, error); + return Effect.succeed(null); + }), + ); - if (cacheAge < ttl) { - return topics; + if (stored) { + // Type guard to ensure stored is a valid CachedTopics object + const isValidCachedTopics = (data: unknown): data is CachedTopics => { + return ( + typeof data === 'object' && + data !== null && + 'topics' in data && + 'timestamp' in data && + Array.isArray((data as any).topics) && + typeof (data as any).timestamp === 'number' + ); + }; + + const cachedTopicsResult = yield* Effect.try({ + try: () => { + if (!isValidCachedTopics(stored)) { + throw new Error('Invalid cached data format'); + } + return stored as CachedTopics; + }, + catch: (error) => new Error(`Invalid cached data: ${error}`), + }).pipe( + Effect.catchAll((error) => { + console.warn(`[getUserTopics] Invalid cached data, regenerating:`, error); + return Effect.succeed(null); + }), + ); + + if (cachedTopicsResult) { + const cacheAge = Date.now() - cachedTopicsResult.timestamp; + + if (cacheAge < TOPIC_CACHE_TTL) { + console.log( + `[getUserTopics] Using cached topics (age: ${Math.round(cacheAge / 1000 / 60)} minutes)`, + ); + result.topics = cachedTopicsResult.topics; + result.cacheHit = true; + result.cacheAge = cacheAge; + return result; + } else { + console.log( + `[getUserTopics] Cache expired (age: ${Math.round(cacheAge / 1000 / 60)} minutes), regenerating`, + ); + } } - } catch { - // Invalid stored data, continue to regenerate } - } - // Generate new topics - const subjects = this.getAllSubjects(); - let existingLabels: { name: string; id: string }[] = []; + // Generate new topics + console.log(`[getUserTopics] Generating new topics`); + const subjects = self.getAllSubjects(); + result.subjectsAnalyzed = subjects.length; + console.log(`[getUserTopics] Found ${subjects.length} subjects for analysis`); - try { - existingLabels = await this.getUserLabels(); - } catch (error) { - console.warn('Failed to get existing labels for topic generation:', error); - } + let existingLabels: { name: string; id: string }[] = []; - const topics = await generateWhatUserCaresAbout(subjects, { existingLabels }); + const existingLabelsResult = yield* Effect.tryPromise(() => self.getUserLabels()).pipe( + Effect.tap((labels) => + Effect.sync(() => { + result.existingLabelsCount = labels.length; + console.log(`[getUserTopics] Retrieved ${labels.length} existing labels`); + }), + ), + Effect.catchAll((error) => { + console.warn( + `[getUserTopics] Failed to get existing labels for topic generation:`, + error, + ); + return Effect.succeed([]); + }), + ); - if (topics.length > 0) { - // Ensure labels exist in user account - try { - const existingLabelNames = new Set(existingLabels.map((label) => label.name.toLowerCase())); - - for (const topic of topics) { - const topicName = topic.topic.toLowerCase(); - if (!existingLabelNames.has(topicName)) { - console.log(`Creating label for topic: ${topic.topic}`); - await this.createLabel({ - name: topic.topic, - }); + existingLabels = existingLabelsResult; + + const topics = yield* Effect.tryPromise(() => + generateWhatUserCaresAbout(subjects, { existingLabels }), + ).pipe( + Effect.tap((topics) => + Effect.sync(() => { + result.topics = topics; + console.log( + `[getUserTopics] Generated ${topics.length} topics:`, + topics.map((t) => t.topic), + ); + }), + ), + Effect.catchAll((error) => { + console.error(`[getUserTopics] Failed to generate topics:`, error); + return Effect.succeed([]); + }), + ); + + if (topics.length > 0) { + console.log(`[getUserTopics] Processing ${topics.length} topics`); + + // Ensure labels exist in user account + yield* Effect.tryPromise(async () => { + try { + const existingLabelNames = new Set( + existingLabels.map((label) => label.name.toLowerCase()), + ); + let createdCount = 0; + + for (const topic of topics) { + const topicName = topic.topic.toLowerCase(); + if (!existingLabelNames.has(topicName)) { + console.log(`[getUserTopics] Creating label for topic: ${topic.topic}`); + await self.createLabel({ + name: topic.topic, + }); + createdCount++; + } + } + result.labelsCreated = createdCount; + console.log(`[getUserTopics] Created ${createdCount} new labels`); + } catch (error) { + console.error(`[getUserTopics] Failed to ensure topic labels exist:`, error); + throw error; } + }).pipe( + Effect.catchAll((error) => { + console.error(`[getUserTopics] Error creating labels:`, error); + return Effect.succeed(undefined); + }), + ); + + // Store the result + yield* Effect.tryPromise(() => + self.ctx.storage.put(TOPIC_CACHE_KEY, { + topics, + timestamp: Date.now(), + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => console.log(`[getUserTopics] Stored topics in cache`)), + ), + Effect.catchAll((error) => { + console.error(`[getUserTopics] Failed to store topics in cache:`, error); + return Effect.succeed(undefined); + }), + ); + + // Broadcast message if agent exists + if (self.agent) { + yield* Effect.tryPromise(() => + self.agent!.broadcastChatMessage({ + type: OutgoingMessageType.User_Topics, + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + result.broadcastSent = true; + console.log(`[getUserTopics] Broadcasted topics update`); + }), + ), + Effect.catchAll((error) => { + console.warn(`[getUserTopics] Failed to broadcast topics update:`, error); + return Effect.succeed(undefined); + }), + ); + } else { + console.log(`[getUserTopics] No agent available for broadcasting`); } - } catch (error) { - console.error('Failed to ensure topic labels exist:', error); + } else { + console.log(`[getUserTopics] No topics generated`); } - // Store the result - await this.ctx.storage.put('user_topics', { - topics, - timestamp: Date.now(), + console.log(`[getUserTopics] Completed topic generation for connection: ${self.name}`, { + topicsCount: result.topics.length, + cacheHit: result.cacheHit, + subjectsAnalyzed: result.subjectsAnalyzed, + existingLabelsCount: result.existingLabelsCount, + labelsCreated: result.labelsCreated, + broadcastSent: result.broadcastSent, }); - await this.agent?.broadcastChatMessage({ - type: OutgoingMessageType.User_Topics, - }); - } + return result; + }); - return topics; + // Run the Effect and extract just the topics for backward compatibility + return Effect.runPromise( + topicGenerationEffect.pipe( + Effect.map((result) => result.topics), + Effect.catchAll((error) => { + console.error(`[getUserTopics] Critical error in getUserTopics:`, error); + return Effect.succeed([]); + }), + ), + ); } async setMetaData(connectionId: string) { @@ -458,86 +828,189 @@ export class ZeroDriver extends AIChatAgent { }); } - async syncThread({ threadId }: { threadId: string }) { - if (this.name === 'general') return; - if (!this.driver) { - await this.setupAuth(); - } + async syncThread({ threadId }: { threadId: string }): Promise { + const self = this; - if (!this.driver) { - console.error('No driver available for syncThread'); - throw new Error('No driver available'); + if (this.name === 'general') { + return { success: true, threadId, broadcastSent: false }; } if (this.syncThreadsInProgress.has(threadId)) { - console.log(`Sync already in progress for thread ${threadId}, skipping...`); - return; + console.log(`[syncThread] Sync already in progress for thread ${threadId}, skipping...`); + return { success: true, threadId, broadcastSent: false }; } - this.syncThreadsInProgress.set(threadId, true); - // console.log('Server: syncThread called for thread', threadId); - try { - const threadData = await this.getWithRetry(threadId); - const latest = threadData.latest; - - if (latest) { - // Convert receivedOn to ISO format for proper sorting - let normalizedReceivedOn: string; - try { - normalizedReceivedOn = new Date(latest.receivedOn).toISOString(); - } catch (error) { - console.log('Here!', error); - normalizedReceivedOn = new Date().toISOString(); + return Effect.runPromise( + Effect.gen(function* () { + console.log(`[syncThread] Starting sync for thread: ${threadId}`); + + const result: ThreadSyncResult = { + success: false, + threadId, + broadcastSent: false, + }; + + // Setup driver if needed + if (!self.driver) { + yield* Effect.tryPromise(() => self.setupAuth()).pipe( + Effect.tap(() => Effect.sync(() => console.log(`[syncThread] Setup auth completed`))), + Effect.catchAll((error) => { + console.error(`[syncThread] Failed to setup auth:`, error); + return Effect.succeed(undefined); + }), + ); } - await env.THREADS_BUCKET.put(this.getThreadKey(threadId), JSON.stringify(threadData), { - customMetadata: { - threadId, - }, + if (!self.driver) { + console.error(`[syncThread] No driver available for thread ${threadId}`); + result.success = false; + result.reason = 'No driver available'; + return result; + } + + self.syncThreadsInProgress.set(threadId, true); + + // Get thread data with retry + const threadData = yield* Effect.tryPromise(() => self.getWithRetry(threadId)).pipe( + Effect.tap(() => + Effect.sync(() => console.log(`[syncThread] Retrieved thread data for ${threadId}`)), + ), + Effect.catchAll((error) => { + console.error(`[syncThread] Failed to get thread data for ${threadId}:`, error); + return Effect.fail( + new ThreadDataError(`Failed to get thread data for ${threadId}`, error), + ); + }), + ); + + const latest = threadData.latest; + + if (!latest) { + self.syncThreadsInProgress.delete(threadId); + console.log(`[syncThread] Skipping thread ${threadId} - no latest message`); + result.success = false; + result.reason = 'No latest message'; + return result; + } + + // Normalize received date + const normalizedReceivedOn = yield* Effect.try({ + try: () => new Date(latest.receivedOn).toISOString(), + catch: (error) => + new DateNormalizationError(`Failed to normalize date for ${threadId}`, error), + }).pipe( + Effect.catchAll((error) => { + console.warn( + `[syncThread] Date normalization failed for ${threadId}, using current date:`, + error, + ); + return Effect.succeed(new Date().toISOString()); + }), + ); + + result.normalizedReceivedOn = normalizedReceivedOn; + + // Store thread data in bucket + yield* Effect.tryPromise(() => + env.THREADS_BUCKET.put(self.getThreadKey(threadId), JSON.stringify(threadData), { + customMetadata: { threadId }, + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => + console.log(`[syncThread] Stored thread data in bucket for ${threadId}`), + ), + ), + Effect.catchAll((error) => { + console.error( + `[syncThread] Failed to store thread data in bucket for ${threadId}:`, + error, + ); + return Effect.succeed(undefined); + }), + ); + + // Update database + yield* Effect.tryPromise(() => + Promise.resolve(self.sql` + INSERT OR REPLACE INTO threads ( + id, + thread_id, + provider_id, + latest_sender, + latest_received_on, + latest_subject, + latest_label_ids, + updated_at + ) VALUES ( + ${threadId}, + ${threadId}, + 'google', + ${JSON.stringify(latest.sender)}, + ${normalizedReceivedOn}, + ${latest.subject}, + ${JSON.stringify(latest.tags.map((tag) => tag.id))}, + CURRENT_TIMESTAMP + ) + `), + ).pipe( + Effect.tap(() => + Effect.sync(() => console.log(`[syncThread] Updated database for ${threadId}`)), + ), + Effect.catchAll((error) => { + console.error(`[syncThread] Failed to update database for ${threadId}:`, error); + return Effect.succeed(undefined); + }), + ); + + // Broadcast update if agent exists + if (self.agent) { + yield* Effect.tryPromise(() => + self.agent!.broadcastChatMessage({ + type: OutgoingMessageType.Mail_Get, + threadId, + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + result.broadcastSent = true; + console.log(`[syncThread] Broadcasted update for ${threadId}`); + }), + ), + Effect.catchAll((error) => { + console.warn(`[syncThread] Failed to broadcast update for ${threadId}:`, error); + return Effect.succeed(undefined); + }), + ); + } else { + console.log(`[syncThread] No agent available for broadcasting ${threadId}`); + } + + self.syncThreadsInProgress.delete(threadId); + + result.success = true; + result.threadData = threadData; + + console.log(`[syncThread] Completed sync for thread: ${threadId}`, { + success: result.success, + broadcastSent: result.broadcastSent, + hasLatestMessage: !!latest, }); - void this.sql` - INSERT OR REPLACE INTO threads ( - id, - thread_id, - provider_id, - latest_sender, - latest_received_on, - latest_subject, - latest_label_ids, - updated_at - ) VALUES ( - ${threadId}, - ${threadId}, - 'google', - ${JSON.stringify(latest.sender)}, - ${normalizedReceivedOn}, - ${latest.subject}, - ${JSON.stringify(latest.tags.map((tag) => tag.id))}, - CURRENT_TIMESTAMP - ) - `; - if (this.agent) - this.agent.broadcastChatMessage({ - type: OutgoingMessageType.Mail_Get, + return result; + }).pipe( + Effect.catchAll((error) => { + self.syncThreadsInProgress.delete(threadId); + console.error(`[syncThread] Critical error syncing thread ${threadId}:`, error); + return Effect.succeed({ + success: false, threadId, + reason: error.message, + broadcastSent: false, }); - this.syncThreadsInProgress.delete(threadId); - // console.log('Server: syncThread result', { - // threadId, - // labels: threadData.labels, - // }); - return { success: true, threadId, threadData }; - } else { - this.syncThreadsInProgress.delete(threadId); - console.log(`Skipping thread ${threadId} - no latest message`); - return { success: false, threadId, reason: 'No latest message' }; - } - } catch (error) { - this.syncThreadsInProgress.delete(threadId); - console.error(`Failed to sync thread ${threadId}:`, error); - throw error; - } + }), + ), + ); } async getThreadCount() { @@ -552,141 +1025,217 @@ export class ZeroDriver extends AIChatAgent { return count[0]['COUNT(*)'] as number; } - async syncThreads(folder: string) { - const startTime = Date.now(); + async syncThreads(folder: string): Promise { + const self = this; + if (!this.driver) { - console.error('No driver available for syncThreads'); - throw new Error('No driver available'); + console.error(`[syncThreads] No driver available for folder ${folder}`); + return { + synced: 0, + message: 'No driver available', + folder, + pagesProcessed: 0, + totalThreads: 0, + successfulSyncs: 0, + failedSyncs: 0, + broadcastSent: false, + }; } if (this.foldersInSync.has(folder)) { - console.log(`Sync already in progress for ${folder}, skipping...`); - return { synced: 0, message: 'Sync already in progress' }; - } - - const threadCount = await this.getThreadCount(); - console.log( - `Thread count for ${folder}: ${threadCount} (max: ${maxCount}, loop: ${shouldLoop})`, - ); - if (threadCount >= maxCount && !shouldLoop) { - console.log(`Threads already synced for ${folder}, skipping...`); - return { synced: 0, message: 'Threads already synced' }; + console.log(`[syncThreads] Sync already in progress for folder ${folder}, skipping...`); + return { + synced: 0, + message: 'Sync already in progress', + folder, + pagesProcessed: 0, + totalThreads: 0, + successfulSyncs: 0, + failedSyncs: 0, + broadcastSent: false, + }; } - this.foldersInSync.set(folder, true); - - const self = this; - - const fetchThread = (threadId: string) => + return Effect.runPromise( Effect.gen(function* () { - yield* Effect.sleep(200); - - const threadData = yield* Effect.tryPromise(() => self.getWithRetry(threadId)); - - if (!threadData || !threadData.latest || !threadData.latest.threadId) { - return 0 as const; - } - const latest = threadData.latest!; - const id = latest.threadId as string; + console.log(`[syncThreads] Starting sync for folder: ${folder}`); + + const result: FolderSyncResult = { + synced: 0, + message: 'Sync completed', + folder, + pagesProcessed: 0, + totalThreads: 0, + successfulSyncs: 0, + failedSyncs: 0, + broadcastSent: false, + }; - const serialized = JSON.stringify(threadData); - yield* Effect.tryPromise(() => - env.THREADS_BUCKET.put(self.getThreadKey(id), serialized, { - customMetadata: { threadId: id }, + // Check thread count + const threadCount = yield* Effect.tryPromise(() => self.getThreadCount()).pipe( + Effect.tap((count) => + Effect.sync(() => console.log(`[syncThreads] Current thread count: ${count}`)), + ), + Effect.catchAll((error) => { + console.warn(`[syncThreads] Failed to get thread count:`, error); + return Effect.succeed(0); }), ); - const normalizedReceivedOn = new Date(latest.receivedOn).toISOString(); - yield* Effect.try( - () => - self.sql` - INSERT OR REPLACE INTO threads ( - id, thread_id, provider_id, latest_sender, - latest_received_on, latest_subject, latest_label_ids, updated_at - ) VALUES ( - ${id}, - ${id}, - 'google', - ${JSON.stringify(latest.sender)}, - ${normalizedReceivedOn}, - ${latest.subject}, - ${JSON.stringify(latest.tags.map((tag) => tag.id))}, - CURRENT_TIMESTAMP - ) - `, - ); + if (threadCount >= maxCount && !shouldLoop) { + console.log(`[syncThreads] Threads already synced (${threadCount}), skipping...`); + result.message = 'Threads already synced'; + return result; + } - self.agent?.broadcastChatMessage({ - type: OutgoingMessageType.Mail_Get, - threadId: id, - }); + self.foldersInSync.set(folder, true); + + // Sync single thread function + const syncSingleThread = (threadId: string) => + Effect.gen(function* () { + yield* Effect.sleep(150); // Rate limiting delay + const syncResult = yield* Effect.tryPromise(() => self.syncThread({ threadId })).pipe( + Effect.tap(() => + Effect.sync(() => + console.log(`[syncThreads] Successfully synced thread ${threadId}`), + ), + ), + Effect.catchAll((error) => { + console.error(`[syncThreads] Failed to sync thread ${threadId}:`, error); + return Effect.succeed({ + success: false, + threadId, + reason: error.message, + broadcastSent: false, + }); + }), + ); - return 1 as const; - }).pipe( - Effect.catchAll((error) => { - console.error(`Failed to process thread ${threadId} in ${folder}:`, error); - return Effect.succeed(0 as const); - }), - ); + if (syncResult.success) { + result.successfulSyncs++; + } else { + result.failedSyncs++; + } + + return syncResult; + }); - const syncProgram = Effect.gen( - function* () { - let totalSynced = 0; + // Main sync program let pageToken: string | null = null; let hasMore = true; while (hasMore) { - yield* Effect.sleep(1500); + result.pagesProcessed++; + + // Rate limiting delay between pages + yield* Effect.sleep(1000); - const result: IGetThreadsResponse = yield* Effect.tryPromise(() => + console.log( + `[syncThreads] Processing page ${result.pagesProcessed} for folder ${folder}`, + ); + + const listResult = yield* Effect.tryPromise(() => self.listWithRetry({ folder, maxResults: maxCount, pageToken: pageToken || undefined, }), + ).pipe( + Effect.tap((listResult) => + Effect.sync(() => { + console.log( + `[syncThreads] Retrieved ${listResult.threads.length} threads from page ${result.pagesProcessed}`, + ); + result.totalThreads += listResult.threads.length; + }), + ), + Effect.catchAll((error) => { + console.error(`[syncThreads] Failed to list threads for folder ${folder}:`, error); + return Effect.fail( + new ThreadListError(`Failed to list threads for folder ${folder}`, error), + ); + }), ); - const threadIds = result.threads.map((thread) => thread.id); - - const processedCounts = yield* Effect.all(threadIds.map(fetchThread), { - concurrency: 3, - }); - - const batchSynced = processedCounts.filter((c) => c === 1).length; - totalSynced += batchSynced; + // Process threads with controlled concurrency to avoid rate limits + const threadIds = listResult.threads.map((thread) => thread.id); + const syncEffects = threadIds.map(syncSingleThread); + + yield* Effect.all(syncEffects, { concurrency: 1, discard: true }).pipe( + Effect.tap(() => + Effect.sync(() => + console.log(`[syncThreads] Completed page ${result.pagesProcessed}`), + ), + ), + Effect.catchAll((error) => { + console.error( + `[syncThreads] Failed to process threads on page ${result.pagesProcessed}:`, + error, + ); + return Effect.succeed(undefined); + }), + ); - pageToken = result.nextPageToken; + result.synced += listResult.threads.length; + pageToken = listResult.nextPageToken; hasMore = pageToken !== null && shouldLoop; } - return { synced: totalSynced }; - }.bind(this), - ); - - try { - const result = await Effect.runPromise( - syncProgram.pipe( - Effect.ensuring( - Effect.sync(() => { - console.log('Setting isSyncing to false'); - this.foldersInSync.delete(folder); - this.agent?.broadcastChatMessage({ - type: OutgoingMessageType.Mail_List, - folder, - }); + // Broadcast completion if agent exists + if (self.agent) { + yield* Effect.tryPromise(() => + self.agent!.broadcastChatMessage({ + type: OutgoingMessageType.Mail_List, + folder, }), - ), - ), - ); - const duration = Date.now() - startTime; - console.log(`[TIMING] syncThreads(${folder}) completed in ${duration}ms`); - return result; - } catch (error) { - const duration = Date.now() - startTime; - console.log(`[TIMING] syncThreads(${folder}) errored after ${duration}ms`); - console.error('Failed to sync inbox threads:', error); - throw error; - } + ).pipe( + Effect.tap(() => + Effect.sync(() => { + result.broadcastSent = true; + console.log(`[syncThreads] Broadcasted completion for folder ${folder}`); + }), + ), + Effect.catchAll((error) => { + console.warn( + `[syncThreads] Failed to broadcast completion for folder ${folder}:`, + error, + ); + return Effect.succeed(undefined); + }), + ); + } else { + console.log(`[syncThreads] No agent available for broadcasting folder ${folder}`); + } + + self.foldersInSync.delete(folder); + + console.log(`[syncThreads] Completed sync for folder: ${folder}`, { + synced: result.synced, + pagesProcessed: result.pagesProcessed, + totalThreads: result.totalThreads, + successfulSyncs: result.successfulSyncs, + failedSyncs: result.failedSyncs, + broadcastSent: result.broadcastSent, + }); + + return result; + }).pipe( + Effect.catchAll((error) => { + self.foldersInSync.delete(folder); + console.error(`[syncThreads] Critical error syncing folder ${folder}:`, error); + return Effect.succeed({ + synced: 0, + message: `Sync failed: ${error.message}`, + folder, + pagesProcessed: 0, + totalThreads: 0, + successfulSyncs: 0, + failedSyncs: 0, + broadcastSent: false, + }); + }), + ), + ); } async inboxRag(query: string) { @@ -1037,7 +1586,7 @@ export class ZeroDriver extends AIChatAgent { let currentLabels: string[]; try { - currentLabels = JSON.parse(result[0].latest_label_ids || '[]') as string[]; + currentLabels = JSON.parse(String(result[0].latest_label_ids || '[]')) as string[]; } catch (error) { console.error(`Invalid JSON in latest_label_ids for thread ${threadId}:`, error); currentLabels = []; diff --git a/apps/server/src/routes/agent/rpc.ts b/apps/server/src/routes/agent/rpc.ts index b5f4553453..ce00e75303 100644 --- a/apps/server/src/routes/agent/rpc.ts +++ b/apps/server/src/routes/agent/rpc.ts @@ -14,9 +14,9 @@ * Reuse or distribution of this file requires a license from Zero Email Inc. */ import type { CreateDraftData } from '../../lib/schemas'; +import { ZeroDriver, type FolderSyncResult } from '.'; import type { IOutgoingMessage } from '../../types'; import { RpcTarget } from 'cloudflare:workers'; -import { ZeroDriver } from '.'; const shouldReSyncThreadsAfterActions = false; @@ -240,7 +240,7 @@ export class DriverRpcDO extends RpcTarget { return await this.mainDo.listHistory(historyId); } - async syncThreads(folder: string) { + async syncThreads(folder: string): Promise { return await this.mainDo.syncThreads(folder); } From a11d21978381b9a216e6930848751e2f948f03d9 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:33:57 -0700 Subject: [PATCH 07/48] Update subscription expiration check to use database instead of KV store (#1846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Switch from KV to Hyperdrive for subscription management ## Description This PR updates the scheduled task that checks for expired subscriptions to use Hyperdrive database instead of KV storage. The changes include: 1. Replacing `env.subscribed_accounts.list()` with a direct database query to find connections with access and refresh tokens 2. Simplifying the connection ID handling by using the database ID directly instead of parsing from key names 3. Adding proper database connection cleanup with `conn.end()` in multiple places to prevent connection leaks 4. Removing redundant logging in the thread workflow when closing database connections ## Type of Change - [x] ⚡ Performance improvement - [x] 🐛 Bug fix (non-breaking change which fixes an issue) ## Areas Affected - [x] Data Storage/Management - [x] Email Integration (Gmail, IMAP, etc.) ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix/feature works ## Additional Notes This change helps prevent potential connection leaks and improves the reliability of subscription management by using the database as the source of truth rather than KV storage. --- apps/server/src/main.ts | 20 +++++++++++--------- apps/server/src/pipelines.effect.ts | 12 +++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 8555d2a785..7ef74828cb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -774,8 +774,13 @@ export default class extends WorkerEntrypoint { async scheduled() { console.log('[SCHEDULED] Checking for expired subscriptions...'); - const allAccounts = await env.subscribed_accounts.list(); - console.log('[SCHEDULED] allAccounts', allAccounts.keys); + const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + const allAccounts = await db.query.connection.findMany({ + where: (fields, { isNotNull, and }) => + and(isNotNull(fields.accessToken), isNotNull(fields.refreshToken)), + }); + await conn.end(); + console.log('[SCHEDULED] allAccounts', allAccounts.length); const now = new Date(); const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); @@ -826,17 +831,14 @@ export default class extends WorkerEntrypoint { ); await Promise.all( - allAccounts.keys.map(async (key) => { - const [connectionId, providerId] = key.name.split('__'); - const lastSubscribed = await env.gmail_sub_age.get(key.name); + allAccounts.map(async ({ id, providerId }) => { + const lastSubscribed = await env.gmail_sub_age.get(id); if (lastSubscribed) { const subscriptionDate = new Date(lastSubscribed); if (subscriptionDate < fiveDaysAgo) { - console.log( - `[SCHEDULED] Found expired Google subscription for connection: ${connectionId}`, - ); - expiredSubscriptions.push({ connectionId, providerId: providerId as EProviders }); + console.log(`[SCHEDULED] Found expired Google subscription for connection: ${id}`); + expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); } } }), diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index a84f70c2f0..29e51c5a24 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -544,6 +544,11 @@ export const runThreadWorkflow = ( catch: (error) => ({ _tag: 'DatabaseError' as const, error }), }); + yield* Effect.tryPromise({ + try: async () => conn.end(), + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + const agent = yield* Effect.tryPromise({ try: async () => await getZeroAgent(foundConnection.id), catch: (error) => ({ _tag: 'DatabaseError' as const, error }), @@ -1006,12 +1011,9 @@ export const runThreadWorkflow = ( }).pipe(Effect.orElse(() => Effect.succeed(null))); yield* Effect.tryPromise({ - try: async () => { - await conn.end(); - console.log('[THREAD_WORKFLOW] Closed database connection'); - }, + try: async () => conn.end(), catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }).pipe(Effect.orElse(() => Effect.succeed(null))); + }); yield* Console.log('[THREAD_WORKFLOW] Thread processing complete'); return 'Thread workflow completed successfully'; From 2e462511162212bd94e5d84649505dd3358b19ab Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:03:45 -0700 Subject: [PATCH 08/48] Add expired subscription handling for non-Google providers (#1847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added support for detecting expired subscriptions for all non-Google providers. - **New Features** - Expired subscriptions are now tracked for providers other than Google. --- apps/server/src/main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7ef74828cb..09b3dcac10 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -840,6 +840,8 @@ export default class extends WorkerEntrypoint { console.log(`[SCHEDULED] Found expired Google subscription for connection: ${id}`); expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); } + } else { + expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); } }), ); From b753afa5b61b62f239dce9901cad702441e36c9c Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:57:52 -0700 Subject: [PATCH 09/48] Remove brain state dependency and fix Gmail subscription key format (#1848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Fix AI Summary Display and Gmail Subscription Key Format ## Description This PR includes three key changes: 1. Removed the conditional rendering of the `AiSummary` component based on `brainState.enabled`, allowing the component to handle its own visibility logic. 2. Fixed the Gmail subscription key format in the server by using a composite key format `${id}__${providerId}` instead of just `id` when accessing the `gmail_sub_age` KV store. 3. Temporarily commented out thread synchronization logic in the agent routes due to issues with Durable Object storage resetting. ## Type of Change - [x] 🐛 Bug fix (non-breaking change which fixes an issue) ## Areas Affected - [x] Email Integration (Gmail, IMAP, etc.) - [x] User Interface/Experience - [x] Data Storage/Management ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings ## Additional Notes The commented-out thread synchronization code includes a TODO note explaining that the Durable Object storage is sometimes resetting. This is a temporary measure until we can properly address the underlying issue. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- apps/mail/components/mail/mail-display.tsx | 4 +--- apps/server/src/main.ts | 2 +- apps/server/src/routes/agent/index.ts | 11 ++++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index 77c735dee6..e6910b1332 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -38,7 +38,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import type { Sender, ParsedMessage, Attachment } from '@/types'; import { useActiveConnection } from '@/hooks/use-connections'; import { useAttachments } from '@/hooks/use-attachments'; -import { useBrainState } from '../../hooks/use-summary'; import { useTRPC } from '@/providers/query-provider'; import { useThreadLabels } from '@/hooks/use-labels'; import { useMutation } from '@tanstack/react-query'; @@ -682,7 +681,6 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: const { labels: threadLabels } = useThreadLabels( emailData.tags ? emailData.tags.map((l) => l.id) : [], ); - const { data: brainState } = useBrainState(); const { data: activeConnection } = useActiveConnection(); const [researchSender, setResearchSender] = useState(null); const [searchQuery, setSearchQuery] = useState(null); @@ -1315,7 +1313,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: })()} - {brainState?.enabled && } + {threadAttachments && threadAttachments.length > 0 && ( )} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 09b3dcac10..7b90870260 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -832,7 +832,7 @@ export default class extends WorkerEntrypoint { await Promise.all( allAccounts.map(async ({ id, providerId }) => { - const lastSubscribed = await env.gmail_sub_age.get(id); + const lastSubscribed = await env.gmail_sub_age.get(`${id}__${providerId}`); if (lastSubscribed) { const subscriptionDate = new Date(lastSubscribed); diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 6fce490b5c..348abbb603 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -1360,12 +1360,13 @@ export class ZeroDriver extends AIChatAgent { try { folder = this.normalizeFolderName(folder); - const folderThreadCount = (await this.count()).find((c) => c.label === folder)?.count; - const currentThreadCount = await this.getThreadCount(); + // TODO: Sometimes the DO storage is resetting + // const folderThreadCount = (await this.count()).find((c) => c.label === folder)?.count; + // const currentThreadCount = await this.getThreadCount(); - if (folderThreadCount && folderThreadCount > currentThreadCount && folder) { - this.ctx.waitUntil(this.syncThreads(folder)); - } + // if (folderThreadCount && folderThreadCount > currentThreadCount && folder) { + // this.ctx.waitUntil(this.syncThreads(folder)); + // } // Build WHERE conditions const whereConditions: string[] = []; From 180133fe27109e937335d38784c1d0af053268e8 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:09:06 -0700 Subject: [PATCH 10/48] Enable workflows in server configuration (#1849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Enabled workflows in the server configuration by setting DISABLE_WORKFLOWS to false. --- apps/server/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/wrangler.jsonc b/apps/server/wrangler.jsonc index 2f1657bbaf..9e2d293623 100644 --- a/apps/server/wrangler.jsonc +++ b/apps/server/wrangler.jsonc @@ -276,7 +276,7 @@ "DROP_AGENT_TABLES": "false", "THREAD_SYNC_MAX_COUNT": "20", "THREAD_SYNC_LOOP": "true", - "DISABLE_WORKFLOWS": "true", + "DISABLE_WORKFLOWS": "false", }, "kv_namespaces": [ { From e455bf000a9f1457c223e42322994716bae14423 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:12:54 -0700 Subject: [PATCH 11/48] Refactor thread workflow to use modular workflow engine (#1852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Refactored Thread Processing with Workflow Engine Architecture ## Description Implemented a modular workflow engine architecture for thread processing, replacing the monolithic approach with a flexible, configurable system. This refactoring moves hardcoded business logic into reusable workflow steps that can be composed and executed dynamically, improving maintainability and extensibility. ## Type of Change - ✨ New feature (non-breaking change which adds functionality) - 🎨 UI/UX improvement - ⚡ Performance improvement ## Areas Affected - [x] Email Integration (Gmail, IMAP, etc.) - [x] Data Storage/Management - [x] Development Workflow ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in complex areas - [x] I have updated the documentation - [x] My changes generate no new warnings ## Additional Notes The new workflow engine provides several key benefits: - Type-safe workflow configuration with TypeScript - Modular functions that can be tested independently - Configurable error handling per workflow step - Reusable components shared between workflows - Cleaner separation of concerns in the main pipeline The implementation includes comprehensive documentation in the README.md file explaining the architecture and how to extend it with new workflows. --- apps/server/src/lib/brain.ts | 4 +- .../factories/google-subscription.factory.ts | 13 +- apps/server/src/lib/server-utils.ts | 14 + apps/server/src/pipelines.effect.ts | 695 ++---------------- apps/server/src/routes/agent/index.ts | 45 +- apps/server/src/routes/agent/rpc.ts | 29 +- .../src/thread-workflow-utils/README.md | 180 +++++ .../thread-workflow-utils/workflow-engine.ts | 238 ++++++ .../workflow-functions.ts | 548 ++++++++++++++ .../thread-workflow-utils/workflow-utils.ts | 122 +++ apps/server/src/trpc/routes/ai/compose.ts | 9 +- apps/server/src/trpc/routes/mail.ts | 49 +- 12 files changed, 1224 insertions(+), 722 deletions(-) create mode 100644 apps/server/src/thread-workflow-utils/README.md create mode 100644 apps/server/src/thread-workflow-utils/workflow-engine.ts create mode 100644 apps/server/src/thread-workflow-utils/workflow-functions.ts create mode 100644 apps/server/src/thread-workflow-utils/workflow-utils.ts diff --git a/apps/server/src/lib/brain.ts b/apps/server/src/lib/brain.ts index 0dc11883a9..a73bce547c 100644 --- a/apps/server/src/lib/brain.ts +++ b/apps/server/src/lib/brain.ts @@ -1,6 +1,7 @@ import { ReSummarizeThread, SummarizeMessage, SummarizeThread } from './brain.fallback.prompts'; import { getSubscriptionFactory } from './factories/subscription-factory.registry'; import { AiChatPrompt, StyledEmailAssistantSystemPrompt } from './prompts'; +import { resetConnection } from './server-utils'; import { EPrompts, EProviders } from '../types'; import { getPromptName } from '../pipelines'; import { env } from 'cloudflare:workers'; @@ -11,6 +12,7 @@ export const enableBrainFunction = async (connection: { id: string; providerId: await subscriptionFactory.subscribe({ body: { connectionId: connection.id } }); } catch (error) { console.error(`Failed to enable brain function: ${error}`); + await resetConnection(connection.id); } }; @@ -47,7 +49,7 @@ export const getPrompts = async ({ connectionId }: { connectionId: string }) => [EPrompts.SummarizeMessage]: SummarizeMessage, [EPrompts.ReSummarizeThread]: ReSummarizeThread, [EPrompts.SummarizeThread]: SummarizeThread, - [EPrompts.Chat]: AiChatPrompt('', '', ''), + [EPrompts.Chat]: AiChatPrompt(''), [EPrompts.Compose]: StyledEmailAssistantSystemPrompt(), // [EPrompts.ThreadLabels]: '', }; diff --git a/apps/server/src/lib/factories/google-subscription.factory.ts b/apps/server/src/lib/factories/google-subscription.factory.ts index d82efc8a00..b231fae89d 100644 --- a/apps/server/src/lib/factories/google-subscription.factory.ts +++ b/apps/server/src/lib/factories/google-subscription.factory.ts @@ -1,6 +1,8 @@ import { BaseSubscriptionFactory, type SubscriptionData } from './base-subscription.factory'; import { c, getNotificationsUrl } from '../../lib/utils'; +import { resetConnection } from '../server-utils'; import jwt from '@tsndr/cloudflare-worker-jwt'; +import { connection } from '../../db/schema'; import { env } from 'cloudflare:workers'; import { EProviders } from '../../types'; @@ -193,7 +195,10 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { } } - private async setupGmailWatch(connectionData: any, topicName: string): Promise { + private async setupGmailWatch( + connectionData: typeof connection.$inferSelect, + topicName: string, + ): Promise { // Create Gmail client with OAuth2 const { OAuth2Client } = await import('google-auth-library'); const auth = new OAuth2Client({ @@ -271,7 +276,11 @@ class GoogleSubscriptionFactory extends BaseSubscriptionFactory { console.log( `[SUBSCRIPTION] Setting up Gmail watch for connection: ${connectionData.id} ${pubSubName}`, ); - await this.setupGmailWatch(connectionData, pubSubName); + await this.setupGmailWatch(connectionData, pubSubName).catch(async (error) => { + console.error('[SUBSCRIPTION] Error setting up Gmail watch:', { error }); + await resetConnection(connectionData.id); + throw error; + }); await env.gmail_sub_age.put( `${connectionId}__${EProviders.google}`, diff --git a/apps/server/src/lib/server-utils.ts b/apps/server/src/lib/server-utils.ts index 0aed7bc369..7bc044cb13 100644 --- a/apps/server/src/lib/server-utils.ts +++ b/apps/server/src/lib/server-utils.ts @@ -3,6 +3,8 @@ import { connection } from '../db/schema'; import type { HonoContext } from '../ctx'; import { env } from 'cloudflare:workers'; import { createDriver } from './driver'; +import { eq } from 'drizzle-orm'; +import { createDb } from '../db'; export const getZeroDB = async (userId: string) => { const stub = env.ZERO_DB.get(env.ZERO_DB.idFromName(userId)); @@ -75,3 +77,15 @@ export const verifyToken = async (token: string) => { const data = (await response.json()) as any; return !!data; }; + +export const resetConnection = async (connectionId: string) => { + const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + await db + .update(connection) + .set({ + accessToken: null, + refreshToken: null, + }) + .where(eq(connection.id, connectionId)); + await conn.end(); +}; diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index 29e51c5a24..848fd64ea9 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -11,26 +11,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - ReSummarizeThread, - SummarizeMessage, - SummarizeThread, - ThreadLabels, -} from './lib/brain.fallback.prompts'; -import { - generateAutomaticDraft, - shouldGenerateDraft, - analyzeEmailIntent, -} from './thread-workflow-utils'; -import { defaultLabels, EPrompts, EProviders, type ParsedMessage, type Sender } from './types'; +import { createDefaultWorkflows } from './thread-workflow-utils/workflow-engine'; import { getServiceAccount } from './lib/factories/google-subscription.factory'; -import { EWorkflowType, getPromptName, runWorkflow } from './pipelines'; +import { EWorkflowType, runWorkflow } from './pipelines'; import { getZeroAgent } from './lib/server-utils'; import { type gmail_v1 } from '@googleapis/gmail'; import { Effect, Console, Logger } from 'effect'; import { env } from 'cloudflare:workers'; import { connection } from './db/schema'; -import * as cheerio from 'cheerio'; +import { EProviders } from './types'; import { eq } from 'drizzle-orm'; import { createDb } from './db'; @@ -507,7 +496,8 @@ type ThreadWorkflowError = | { _tag: 'UnsupportedProvider'; providerId: string } | { _tag: 'DatabaseError'; error: unknown } | { _tag: 'GmailApiError'; error: unknown } - | { _tag: 'VectorizationError'; error: unknown }; + | { _tag: 'VectorizationError'; error: unknown } + | { _tag: 'WorkflowCreationFailed'; error: unknown }; /** * Runs the main workflow for processing a thread. The workflow is responsible for processing incoming messages from a Pub/Sub subscription and passing them to the appropriate pipeline. @@ -569,436 +559,68 @@ export const runThreadWorkflow = ( return 'Thread has no messages'; } - const autoDraftId = yield* Effect.tryPromise({ - try: async () => { - if (!shouldGenerateDraft(thread, foundConnection)) { - console.log('[THREAD_WORKFLOW] Skipping draft generation for thread:', threadId); - return null; - } - - const latestMessage = thread.messages[thread.messages.length - 1]; - const emailIntent = analyzeEmailIntent(latestMessage); - - console.log('[THREAD_WORKFLOW] Analyzed email intent:', { - threadId, - isQuestion: emailIntent.isQuestion, - isRequest: emailIntent.isRequest, - isMeeting: emailIntent.isMeeting, - isUrgent: emailIntent.isUrgent, - }); - - if ( - !emailIntent.isQuestion && - !emailIntent.isRequest && - !emailIntent.isMeeting && - !emailIntent.isUrgent - ) { - console.log( - '[THREAD_WORKFLOW] Email does not require a response, skipping draft generation', - ); - return null; - } - - console.log('[THREAD_WORKFLOW] Generating automatic draft for thread:', threadId); - const draftContent = await generateAutomaticDraft( - connectionId.toString(), - thread, - foundConnection, - ); - - if (draftContent) { - const latestMessage = thread.messages[thread.messages.length - 1]; - - const replyTo = latestMessage.sender?.email || ''; - const cc = - latestMessage.cc - ?.map((r) => r.email) - .filter((email) => email && email !== foundConnection.email) || []; - - const originalSubject = latestMessage.subject || ''; - const replySubject = originalSubject.startsWith('Re: ') - ? originalSubject - : `Re: ${originalSubject}`; - - const draftData = { - to: replyTo, - cc: cc.join(', '), - bcc: '', - subject: replySubject, - message: draftContent, - attachments: [], - id: null, - threadId: threadId.toString(), - fromEmail: foundConnection.email, - }; - - try { - const createdDraft = await agent.createDraft(draftData); - console.log('[THREAD_WORKFLOW] Created automatic draft:', { - threadId, - draftId: createdDraft?.id, - }); - return createdDraft?.id || null; - } catch (error) { - console.log('[THREAD_WORKFLOW] Failed to create automatic draft:', { - threadId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } - - return null; - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - yield* Console.log('[THREAD_WORKFLOW] ' + autoDraftId); - - yield* Console.log('[THREAD_WORKFLOW] Processing thread messages and vectorization'); + // Initialize workflow engine with default workflows + const workflowEngine = createDefaultWorkflows(); + + // Create workflow context + const workflowContext = { + connectionId: connectionId.toString(), + threadId: threadId.toString(), + thread, + foundConnection, + agent, + env, + }; - const messagesToVectorize = yield* Effect.tryPromise({ + // Execute configured workflows using the workflow engine + const workflowResults = yield* Effect.tryPromise({ try: async () => { - console.log('[THREAD_WORKFLOW] Finding messages to vectorize'); - console.log('[THREAD_WORKFLOW] Getting message IDs from thread'); - const messageIds = thread.messages.map((message) => message.id); - console.log('[THREAD_WORKFLOW] Found message IDs:', messageIds); - - console.log('[THREAD_WORKFLOW] Fetching existing vectorized messages'); - const existingMessages = await env.VECTORIZE_MESSAGE.getByIds(messageIds); - console.log('[THREAD_WORKFLOW] Found existing messages:', existingMessages.length); + const allResults = new Map(); + const allErrors = new Map(); - const existingMessageIds = new Set(existingMessages.map((message) => message.id)); - console.log('[THREAD_WORKFLOW] Existing message IDs:', Array.from(existingMessageIds)); + // Execute all workflows registered in the engine + const workflowNames = workflowEngine.getWorkflowNames(); - const messagesToVectorize = thread.messages.filter( - (message) => !existingMessageIds.has(message.id), - ); - console.log('[THREAD_WORKFLOW] Messages to vectorize:', messagesToVectorize.length); - - return messagesToVectorize; - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); + for (const workflowName of workflowNames) { + console.log(`[THREAD_WORKFLOW] Executing workflow: ${workflowName}`); - let finalEmbeddings: VectorizeVector[] = []; - - if (messagesToVectorize.length === 0) { - yield* Console.log('[THREAD_WORKFLOW] No messages to vectorize, skipping vectorization'); - } else { - finalEmbeddings = yield* Effect.tryPromise({ - try: async () => { - console.log( - '[THREAD_WORKFLOW] Starting message vectorization for', - messagesToVectorize.length, - 'messages', - ); - - const maxConcurrentMessages = 3; - const results: VectorizeVector[] = []; - - for (let i = 0; i < messagesToVectorize.length; i += maxConcurrentMessages) { - const batch = messagesToVectorize.slice(i, i + maxConcurrentMessages); - const batchResults = await Promise.all( - batch.map(async (message) => { - try { - console.log('[THREAD_WORKFLOW] Converting message to XML:', message.id); - const prompt = await messageToXML(message); - if (!prompt) { - console.log('[THREAD_WORKFLOW] Message has no prompt, skipping:', message.id); - return null; - } - console.log('[THREAD_WORKFLOW] Got XML prompt for message:', message.id); - - console.log( - '[THREAD_WORKFLOW] Getting summarize prompt for connection:', - message.connectionId ?? '', - ); - const SummarizeMessagePrompt = await getPrompt( - getPromptName(message.connectionId ?? '', EPrompts.SummarizeMessage), - SummarizeMessage, - ); - console.log('[THREAD_WORKFLOW] Got summarize prompt for message:', message.id); - - console.log('[THREAD_WORKFLOW] Generating summary for message:', message.id); - const messages = [ - { role: 'system', content: SummarizeMessagePrompt }, - { role: 'user', content: prompt }, - ]; - const response = await env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', { - messages, - }); - console.log( - `[THREAD_WORKFLOW] Summary generated for message ${message.id}:`, - response, - ); - const summary = 'response' in response ? response.response : response; - if (!summary || typeof summary !== 'string') { - throw new Error(`Invalid summary response for message ${message.id}`); - } - - console.log( - '[THREAD_WORKFLOW] Getting embedding vector for message:', - message.id, - ); - const embeddingVector = await getEmbeddingVector(summary); - console.log('[THREAD_WORKFLOW] Got embedding vector for message:', message.id); - - if (!embeddingVector) - throw new Error(`Message Embedding vector is null ${message.id}`); - - return { - id: message.id, - metadata: { - connection: message.connectionId ?? '', - thread: message.threadId ?? '', - summary, - }, - values: embeddingVector, - } satisfies VectorizeVector; - } catch (error) { - console.log('[THREAD_WORKFLOW] Failed to vectorize message:', { - messageId: message.id, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }), + try { + const { results, errors } = await workflowEngine.executeWorkflow( + workflowName, + workflowContext, ); - const validResults = batchResults.filter( - (result): result is NonNullable => result !== null, - ); - results.push(...validResults); + // Merge results and errors using efficient Map operations + results.forEach((value, key) => allResults.set(key, value)); + errors.forEach((value, key) => allErrors.set(key, value)); - if (i + maxConcurrentMessages < messagesToVectorize.length) { - console.log('[THREAD_WORKFLOW] Sleeping between message batches'); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } + console.log(`[THREAD_WORKFLOW] Completed workflow: ${workflowName}`); + } catch (error) { + console.error(`[THREAD_WORKFLOW] Failed to execute workflow ${workflowName}:`, error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + allErrors.set(workflowName, errorObj); } - - return results; - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), - }); - - yield* Console.log('[THREAD_WORKFLOW] Generated embeddings for all messages'); - - if (finalEmbeddings.length > 0) { - yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Upserting message vectors:', finalEmbeddings.length); - await env.VECTORIZE_MESSAGE.upsert(finalEmbeddings); - console.log('[THREAD_WORKFLOW] Successfully upserted message vectors'); - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), - }); - } - } - - const existingThreadSummary = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Getting existing thread summary for:', threadId); - const threadSummary = await env.VECTORIZE.getByIds([threadId.toString()]); - if (!threadSummary.length) { - console.log('[THREAD_WORKFLOW] No existing thread summary found'); - return null; - } - console.log('[THREAD_WORKFLOW] Found existing thread summary'); - return threadSummary[0].metadata as { summary: string; lastMsg: string }; - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), - }); - - // Early exit if no new messages (prevents infinite loop from label changes) - const newestMessage = thread.messages[thread.messages.length - 1]; - if (existingThreadSummary && existingThreadSummary.lastMsg === newestMessage?.id) { - yield* Console.log( - '[THREAD_WORKFLOW] No new messages since last processing, skipping AI processing', - ); - return 'Thread workflow completed - no new messages'; - } - - const finalSummary = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Generating final thread summary'); - if (existingThreadSummary) { - console.log('[THREAD_WORKFLOW] Using existing summary as context'); - return await summarizeThread( - connectionId.toString(), - thread.messages, - existingThreadSummary.summary, - ); - } else { - console.log('[THREAD_WORKFLOW] Generating new summary without context'); - return await summarizeThread(connectionId.toString(), thread.messages); } - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), - }); - const userAccountLabels = yield* Effect.tryPromise({ - try: async () => { - const userAccountLabels = await agent.getUserLabels(); - return userAccountLabels; + return { results: allResults, errors: allErrors }; }, - catch: (error) => ({ _tag: 'GmailApiError' as const, error }), + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), }); - if (finalSummary) { - yield* Console.log('[THREAD_WORKFLOW] Got final summary, processing labels'); - - const userLabels = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Getting user topics for connection:', connectionId); - let userLabels: { name: string; usecase: string }[] = []; - try { - const userTopics = await agent.getUserTopics(); - if (userTopics.length > 0) { - userLabels = userTopics.map((topic) => ({ - name: topic.topic, - usecase: topic.usecase, - })); - console.log('[THREAD_WORKFLOW] Using user topics as labels:', userLabels); - } else { - console.log('[THREAD_WORKFLOW] No user topics found, using defaults'); - userLabels = defaultLabels; - } - } catch (error) { - console.log('[THREAD_WORKFLOW] Failed to get user topics, using defaults:', error); - userLabels = defaultLabels; - } - return userLabels; - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - const generatedLabels = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Generating labels for thread:', { - userLabels, - threadId, - threadLabels: thread.labels, - }); - const labelsResponse: any = await env.AI.run( - '@cf/meta/llama-3.3-70b-instruct-fp8-fast', - { - messages: [ - { role: 'system', content: ThreadLabels(userLabels, thread.labels) }, - { role: 'user', content: finalSummary }, - ], - }, - ); - if (labelsResponse?.response?.replaceAll('!', '').trim()?.length) { - console.log('[THREAD_WORKFLOW] Labels generated:', labelsResponse.response); - const labels: string[] = labelsResponse?.response - ?.split(',') - .map((e: string) => e.trim()) - .filter((e: string) => e.length > 0) - .filter((e: string) => - userLabels.find((label) => label.name.toLowerCase() === e.toLowerCase()), - ); - return labels; - } else { - console.log('[THREAD_WORKFLOW] No labels generated'); - return []; - } - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), - }).pipe(Effect.orElse(() => Effect.succeed([]))); - - if (generatedLabels && generatedLabels.length > 0) { - yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Modifying thread labels:', generatedLabels); - const validLabelIds = generatedLabels - .map((name) => userAccountLabels.find((e) => e.name === name)?.id) - .filter((id): id is string => id !== undefined && id !== ''); - - if (validLabelIds.length > 0) { - // Check delta - only modify if there are actual changes - const currentLabelIds = thread.labels?.map((l) => l.id) || []; - const labelsToAdd = validLabelIds.filter((id) => !currentLabelIds.includes(id)); - const aiLabelIds = new Set( - userAccountLabels - .filter((l) => userLabels.some((ul) => ul.name === l.name)) - .map((l) => l.id), - ); - const labelsToRemove = currentLabelIds.filter( - (id) => aiLabelIds.has(id) && !validLabelIds.includes(id), - ); - - if (labelsToAdd.length > 0 || labelsToRemove.length > 0) { - console.log('[THREAD_WORKFLOW] Applying label changes:', { - add: labelsToAdd, - remove: labelsToRemove, - }); - await agent.modifyThreadLabelsInDB( - threadId.toString(), - labelsToAdd, - labelsToRemove, - ); - // await agent.modifyLabels( - // [threadId.toString()], - // labelsToAdd, - // labelsToRemove, - // true, - // ); - // await agent.syncThread({ threadId: threadId.toString() }); - console.log('[THREAD_WORKFLOW] Successfully modified thread labels'); - } else { - console.log('[THREAD_WORKFLOW] No label changes needed - labels already match'); - } - } - }, - catch: (error) => ({ _tag: 'GmailApiError' as const, error }), - }); - } - - const embeddingVector = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Getting thread embedding vector'); - const embeddingVector = await getEmbeddingVector(finalSummary); - console.log('[THREAD_WORKFLOW] Got thread embedding vector'); - return embeddingVector; - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), - }); + // Log workflow results + const successfulSteps = Array.from(workflowResults.results.keys()); + const failedSteps = Array.from(workflowResults.errors.keys()); - if (!embeddingVector) { - yield* Console.log( - '[THREAD_WORKFLOW] Thread Embedding vector is null, skipping vector upsert', - ); - return 'Thread workflow completed successfully'; - } + if (successfulSteps.length > 0) { + yield* Console.log('[THREAD_WORKFLOW] Successfully executed steps:', successfulSteps); + } - yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Upserting thread vector'); - const newestMessage = thread.messages[thread.messages.length - 1]; - await env.VECTORIZE.upsert([ - { - id: threadId.toString(), - metadata: { - connection: connectionId.toString(), - thread: threadId.toString(), - summary: finalSummary, - lastMsg: newestMessage?.id, // Store last message ID to prevent reprocessing - }, - values: embeddingVector, - }, - ]); - console.log('[THREAD_WORKFLOW] Successfully upserted thread vector'); - }, - catch: (error) => ({ _tag: 'VectorizationError' as const, error }), + if (failedSteps.length > 0) { + yield* Console.log('[THREAD_WORKFLOW] Failed steps:', failedSteps); + // Log errors efficiently using forEach to avoid nested iteration + workflowResults.errors.forEach((error, stepId) => { + console.log(`[THREAD_WORKFLOW] Error in step ${stepId}:`, error.message); }); - } else { - yield* Console.log( - '[THREAD_WORKFLOW] No summary generated for thread', - threadId, - 'messages count:', - thread.messages.length, - ); } // Clean up thread processing flag @@ -1048,79 +670,6 @@ export const runThreadWorkflow = ( Effect.provide(loggerLayer), ); -// // Helper functions for vectorization and AI processing -// type VectorizeVectorMetadata = 'connection' | 'thread' | 'summary'; -// type IThreadSummaryMetadata = Record; - -export async function htmlToText(decodedBody: string): Promise { - try { - if (!decodedBody || typeof decodedBody !== 'string') { - return ''; - } - const $ = cheerio.load(decodedBody); - $('script').remove(); - $('style').remove(); - return $('body') - .text() - .replace(/\r?\n|\r/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - } catch (error) { - log('Error extracting text from HTML:', error); - return ''; - } -} - -const escapeXml = (text: string): string => { - if (!text) return ''; - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -}; - -const messageToXML = async (message: ParsedMessage) => { - try { - if (!message.decodedBody) return null; - const body = await htmlToText(message.decodedBody || ''); - log('[MESSAGE_TO_XML] Body', body); - if (!body || body.length < 10) { - log('Skipping message with body length < 10', body); - return null; - } - - const safeSenderName = escapeXml(message.sender?.name || 'Unknown'); - const safeSubject = escapeXml(message.subject || ''); - const safeDate = escapeXml(message.receivedOn || ''); - - const toElements = (message.to || []) - .map((r) => `${escapeXml(r?.email || '')}`) - .join(''); - const ccElements = (message.cc || []) - .map((r) => `${escapeXml(r?.email || '')}`) - .join(''); - - return ` - - ${safeSenderName} - ${toElements} - ${ccElements} - ${safeDate} - ${safeSubject} - ${escapeXml(body)} - - `; - } catch (error) { - log('[MESSAGE_TO_XML] Failed to convert message to XML:', { - messageId: message.id, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -}; - export const getPrompt = async (promptName: string, fallback: string) => { try { if (!promptName || typeof promptName !== 'string') { @@ -1166,147 +715,3 @@ export const getEmbeddingVector = async (text: string) => { return null; } }; - -const getParticipants = (messages: ParsedMessage[]) => { - if (!messages || !Array.isArray(messages) || messages.length === 0) { - return []; - } - - const result = new Map(); - const setIfUnset = (sender: Sender) => { - if (sender?.email && !result.has(sender.email)) { - result.set(sender.email, sender.name || ''); - } - }; - - for (const msg of messages) { - if (msg?.sender) { - setIfUnset(msg.sender); - } - if (msg?.cc && Array.isArray(msg.cc)) { - for (const ccParticipant of msg.cc) { - if (ccParticipant) setIfUnset(ccParticipant); - } - } - if (msg?.to && Array.isArray(msg.to)) { - for (const toParticipant of msg.to) { - if (toParticipant) setIfUnset(toParticipant); - } - } - } - return Array.from(result.entries()); -}; - -const threadToXML = async (messages: ParsedMessage[], existingSummary?: string) => { - if (!messages || !Array.isArray(messages) || messages.length === 0) { - throw new Error('No messages provided for thread XML generation'); - } - - const firstMessage = messages[0]; - if (!firstMessage) { - throw new Error('First message is null or undefined'); - } - - const { subject = '', title = '' } = firstMessage; - const participants = getParticipants(messages); - const messagesXML = await Promise.all(messages.map(messageToXML)); - const validMessagesXML = messagesXML.filter((xml): xml is string => xml !== null); - - if (existingSummary) { - return ` - ${escapeXml(title)} - ${escapeXml(subject)} - - ${participants.map(([email, name]) => { - return `${escapeXml(name || email)} ${name ? `< ${escapeXml(email)} >` : ''}`; - })} - - - ${escapeXml(existingSummary)} - - - ${validMessagesXML.map((e) => e + '\n')} - - `; - } - return ` - ${escapeXml(title)} - ${escapeXml(subject)} - - ${participants.map(([email, name]) => { - return `${escapeXml(name || email)} < ${escapeXml(email)} >`; - })} - - - ${validMessagesXML.map((e) => e + '\n')} - - `; -}; - -const summarizeThread = async ( - connectionId: string, - messages: ParsedMessage[], - existingSummary?: string, -): Promise => { - try { - if (!messages || !Array.isArray(messages) || messages.length === 0) { - log('[SUMMARIZE_THREAD] No messages provided for summarization'); - return null; - } - - if (!connectionId || typeof connectionId !== 'string') { - log('[SUMMARIZE_THREAD] Invalid connection ID provided'); - return null; - } - - const prompt = await threadToXML(messages, existingSummary); - if (!prompt) { - log('[SUMMARIZE_THREAD] Failed to generate thread XML'); - return null; - } - - if (existingSummary) { - const ReSummarizeThreadPrompt = await getPrompt( - getPromptName(connectionId, EPrompts.ReSummarizeThread), - ReSummarizeThread, - ); - const promptMessages = [ - { role: 'system', content: ReSummarizeThreadPrompt }, - { - role: 'user', - content: prompt, - }, - ]; - const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { - messages: promptMessages, - }); - const summary = response?.response; - return typeof summary === 'string' ? summary : null; - } else { - const SummarizeThreadPrompt = await getPrompt( - getPromptName(connectionId, EPrompts.SummarizeThread), - SummarizeThread, - ); - const promptMessages = [ - { role: 'system', content: SummarizeThreadPrompt }, - { - role: 'user', - content: prompt, - }, - ]; - const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { - messages: promptMessages, - }); - const summary = response?.response; - return typeof summary === 'string' ? summary : null; - } - } catch (error) { - log('[SUMMARIZE_THREAD] Failed to summarize thread:', { - connectionId, - messageCount: messages?.length || 0, - hasExistingSummary: !!existingSummary, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -}; diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 348abbb603..5484fdced1 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -636,25 +636,25 @@ export class ZeroDriver extends AIChatAgent { return await this.getThreadFromDB(threadId); } - async markThreadsRead(threadIds: string[]) { - if (!this.driver) { - throw new Error('No driver available'); - } - return await this.driver.modifyLabels(threadIds, { - addLabels: [], - removeLabels: ['UNREAD'], - }); - } - - async markThreadsUnread(threadIds: string[]) { - if (!this.driver) { - throw new Error('No driver available'); - } - return await this.driver.modifyLabels(threadIds, { - addLabels: ['UNREAD'], - removeLabels: [], - }); - } + // async markThreadsRead(threadIds: string[]) { + // if (!this.driver) { + // throw new Error('No driver available'); + // } + // return await this.driver.modifyLabels(threadIds, { + // addLabels: [], + // removeLabels: ['UNREAD'], + // }); + // } + + // async markThreadsUnread(threadIds: string[]) { + // if (!this.driver) { + // throw new Error('No driver available'); + // } + // return await this.driver.modifyLabels(threadIds, { + // addLabels: ['UNREAD'], + // removeLabels: [], + // }); + // } async modifyLabels(threadIds: string[], addLabelIds: string[], removeLabelIds: string[]) { if (!this.driver) { @@ -807,6 +807,7 @@ export class ZeroDriver extends AIChatAgent { } async dropTables() { + console.log('Dropping tables'); return this.sql` DROP TABLE IF EXISTS threads;`; } @@ -1577,7 +1578,7 @@ export class ZeroDriver extends AIChatAgent { const result = this.sql` SELECT latest_label_ids FROM threads - WHERE id = ${threadId} + WHERE thread_id = ${threadId} LIMIT 1 `; @@ -1615,7 +1616,7 @@ export class ZeroDriver extends AIChatAgent { UPDATE threads SET latest_label_ids = ${JSON.stringify(updatedLabels)}, updated_at = CURRENT_TIMESTAMP - WHERE id = ${threadId} + WHERE thread_id = ${threadId} `; await this.agent?.broadcastChatMessage({ @@ -1649,7 +1650,7 @@ export class ZeroDriver extends AIChatAgent { created_at, updated_at FROM threads - WHERE id = ${id} + WHERE thread_id = ${id} LIMIT 1 `; diff --git a/apps/server/src/routes/agent/rpc.ts b/apps/server/src/routes/agent/rpc.ts index ce00e75303..5efbc2ace6 100644 --- a/apps/server/src/routes/agent/rpc.ts +++ b/apps/server/src/routes/agent/rpc.ts @@ -91,7 +91,9 @@ export class DriverRpcDO extends RpcTarget { } async markThreadsRead(threadIds: string[]) { - const result = await this.mainDo.markThreadsRead(threadIds); + const result = await Promise.all( + threadIds.map((id) => this.mainDo.modifyThreadLabelsInDB(id, [], ['UNREAD'])), + ); if (shouldReSyncThreadsAfterActions) await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; @@ -102,20 +104,19 @@ export class DriverRpcDO extends RpcTarget { } async markThreadsUnread(threadIds: string[]) { - const result = await this.mainDo.markThreadsUnread(threadIds); + const result = await Promise.all( + threadIds.map((id) => this.mainDo.modifyThreadLabelsInDB(id, ['UNREAD'], [])), + ); if (shouldReSyncThreadsAfterActions) await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } - async modifyLabels( - threadIds: string[], - addLabelIds: string[], - removeLabelIds: string[], - skipSync: boolean = false, - ) { - const result = await this.mainDo.modifyLabels(threadIds, addLabelIds, removeLabelIds); - if (!skipSync) + async modifyLabels(threadIds: string[], addLabelIds: string[], removeLabelIds: string[]) { + const result = await Promise.all( + threadIds.map((id) => this.mainDo.modifyThreadLabelsInDB(id, addLabelIds, removeLabelIds)), + ); + if (shouldReSyncThreadsAfterActions) await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } @@ -147,14 +148,18 @@ export class DriverRpcDO extends RpcTarget { // } async markAsRead(threadIds: string[]) { - const result = await this.mainDo.markAsRead(threadIds); + const result = await Promise.all( + threadIds.map((id) => this.mainDo.modifyThreadLabelsInDB(id, [], ['UNREAD'])), + ); if (shouldReSyncThreadsAfterActions) await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; } async markAsUnread(threadIds: string[]) { - const result = await this.mainDo.markAsUnread(threadIds); + const result = await Promise.all( + threadIds.map((id) => this.mainDo.modifyThreadLabelsInDB(id, ['UNREAD'], [])), + ); if (shouldReSyncThreadsAfterActions) await Promise.all(threadIds.map((id) => this.mainDo.syncThread({ threadId: id }))); return result; diff --git a/apps/server/src/thread-workflow-utils/README.md b/apps/server/src/thread-workflow-utils/README.md new file mode 100644 index 0000000000..684a8b8e58 --- /dev/null +++ b/apps/server/src/thread-workflow-utils/README.md @@ -0,0 +1,180 @@ +# Thread Workflow Engine + +This module provides a flexible workflow abstraction system for processing email threads. Workflows are defined in code using the WorkflowEngine class and executed through a modular function registry. + +## Architecture + +### Components + +1. **WorkflowEngine** (`workflow-engine.ts`) - Core engine that executes workflows +2. **Workflow Functions** (`workflow-functions.ts`) - Registry of individual workflow functions +3. **Default Workflows** - Pre-configured workflows in `createDefaultWorkflows()` +4. **Workflow Context** - Shared context passed between workflow steps + +### Key Benefits + +- **Type-Safe Configuration**: Workflows are defined in TypeScript, providing better type safety and IDE support +- **Modular Functions**: Each workflow step is a separate function that can be tested independently +- **Error Handling**: Configurable error handling per step (continue, fail, retry) +- **Reusability**: Functions can be shared between different workflows +- **Clean Separation**: The main pipeline is now much cleaner and focused on orchestration + +## Usage + +### Defining a Workflow + +Workflows are defined in the `createDefaultWorkflows()` function in `workflow-engine.ts`: + +```typescript +const autoDraftWorkflow: WorkflowDefinition = { + name: 'auto-draft-generation', + description: 'Automatically generates drafts for threads that require responses', + steps: [ + { + id: 'check-draft-eligibility', + name: 'Check Draft Eligibility', + description: 'Determines if a draft should be generated for this thread', + enabled: true, + condition: async (context) => { + return shouldGenerateDraft(context.thread, context.foundConnection); + }, + action: async (context) => { + console.log('[WORKFLOW_ENGINE] Thread eligible for draft generation', context); + return { eligible: true }; + }, + }, + // ... more steps + ], +}; + +engine.registerWorkflow(autoDraftWorkflow); +``` + +### Adding a New Workflow Function + +1. Add the function to `workflow-functions.ts`: + +```typescript +export const workflowFunctions: Record = { + // ... existing functions ... + + myNewFunction: async (context) => { + // Your logic here + console.log('[WORKFLOW_FUNCTIONS] Executing my new function'); + + // Access previous step results + const previousResult = context.results?.get('previous-step-id'); + + // Return result for next steps + return { success: true, data: 'some data' }; + }, +}; +``` + +2. Add the step to your workflow definition in `createDefaultWorkflows()`: + +```typescript +{ + id: 'my-new-step', + name: 'My New Step', + description: 'Description of what this step does', + enabled: true, + action: async (context) => { + // Your logic here + return { success: true }; + }, + errorHandling: 'continue', +} +``` + +### Workflow Context + +Each workflow function receives a context object with: + +```typescript +type WorkflowContext = { + connectionId: string; + threadId: string; + thread: IGetThreadResponse; + foundConnection: typeof connection.$inferSelect; + results?: Map; // Results from previous steps +}; +``` + +### Error Handling + +Each step can have different error handling strategies: + +- `"continue"` - Skip failed steps and continue with the workflow +- `"fail"` - Stop the entire workflow on error +- `"retry"` - Retry the step up to `maxRetries` times + +### Example: Adding a New Workflow + +1. **Define the workflow in `createDefaultWorkflows()`**: + +```typescript +const customWorkflow: WorkflowDefinition = { + name: 'custom-processing', + description: 'Custom thread processing workflow', + steps: [ + { + id: 'custom-step-1', + name: 'Custom Step 1', + description: 'First custom processing step', + enabled: true, + action: async (context) => { + console.log('[WORKFLOW_ENGINE] Executing custom step 1'); + // Your custom logic here + return { processed: true }; + }, + errorHandling: 'continue', + }, + ], +}; + +engine.registerWorkflow(customWorkflow); +``` + +2. **The workflow will be automatically executed** - no need to update any other configuration! + +### Dynamic Workflow Discovery + +The workflow engine automatically discovers and executes all registered workflows: + +```typescript +// Get all available workflow names from the engine +const workflowNames = workflowEngine.getWorkflowNames(); + +// Execute all workflows dynamically +for (const workflowName of workflowNames) { + const { results, errors } = await workflowEngine.executeWorkflow(workflowName, context); +} +``` + +This means: + +- **No hardcoded workflow lists**: Workflows are discovered automatically +- **Easy to add new workflows**: Just register them in `createDefaultWorkflows()` +- **Conditional workflows**: Can be enabled/disabled at runtime +- **Future-proof**: New workflows are automatically included + +## Migration from Hardcoded Logic + +The original hardcoded logic in `runThreadWorkflow` has been replaced with: + +1. **Workflow Engine**: Orchestrates the execution of workflows +2. **Function Registry**: Contains all the individual processing functions +3. **JSON Configuration**: Defines which workflows and steps to execute +4. **Context Sharing**: Allows steps to share data and results + +This makes the system much more maintainable and allows for easy addition of new processing steps without modifying the core pipeline logic. + +## Benefits + +- **Cleaner Code**: The main pipeline is now focused on orchestration rather than business logic +- **Easier Testing**: Each workflow function can be tested independently +- **Flexible Configuration**: Workflows can be enabled/disabled and modified via JSON +- **Better Error Handling**: Granular error handling per step +- **Reusability**: Functions can be shared between different workflows +- **Maintainability**: Adding new processing steps doesn't require modifying the main pipeline diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts new file mode 100644 index 0000000000..345b8c2c60 --- /dev/null +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -0,0 +1,238 @@ +import type { IGetThreadResponse } from '../lib/driver/types'; +import { workflowFunctions } from './workflow-functions'; +import { shouldGenerateDraft } from './index'; +import { connection } from '../db/schema'; + +export type WorkflowContext = { + connectionId: string; + threadId: string; + thread: IGetThreadResponse; + foundConnection: typeof connection.$inferSelect; + results?: Map; +}; + +export type WorkflowStep = { + id: string; + name: string; + description: string; + enabled: boolean; + condition?: (context: WorkflowContext) => boolean | Promise; + action: (context: WorkflowContext) => Promise; + errorHandling?: 'continue' | 'fail'; + maxRetries?: number; +}; + +export type WorkflowDefinition = { + name: string; + description: string; + steps: WorkflowStep[]; +}; + +export class WorkflowEngine { + private workflows: Map = new Map(); + + registerWorkflow(workflow: WorkflowDefinition) { + this.workflows.set(workflow.name, workflow); + } + + getWorkflowNames(): string[] { + return Array.from(this.workflows.keys()); + } + + async executeWorkflow( + workflowName: string, + context: WorkflowContext, + ): Promise<{ results: Map; errors: Map }> { + const workflow = this.workflows.get(workflowName); + if (!workflow) { + throw new Error(`Workflow "${workflowName}" not found`); + } + + const results = new Map(); + const errors = new Map(); + + for (const step of workflow.steps) { + if (!step.enabled) { + console.log(`[WORKFLOW_ENGINE] Skipping disabled step: ${step.name}`); + continue; + } + + try { + const shouldExecute = step.condition ? await step.condition(context) : true; + if (!shouldExecute) { + console.log(`[WORKFLOW_ENGINE] Condition not met for step: ${step.name}`); + continue; + } + + console.log(`[WORKFLOW_ENGINE] Executing step: ${step.name}`); + const result = await step.action({ ...context, results }); + results.set(step.id, result); + console.log(`[WORKFLOW_ENGINE] Completed step: ${step.name}`); + } catch (error) { + const errorObj = error instanceof Error ? error : new Error(String(error)); + console.error(`[WORKFLOW_ENGINE] Error in step ${step.name}:`, errorObj); + + if (step.errorHandling === 'fail') { + throw errorObj; + } else { + errors.set(step.id, errorObj); + } + } + } + + return { results, errors }; + } +} + +export const createDefaultWorkflows = (): WorkflowEngine => { + const engine = new WorkflowEngine(); + + const autoDraftWorkflow: WorkflowDefinition = { + name: 'auto-draft-generation', + description: 'Automatically generates drafts for threads that require responses', + steps: [ + { + id: 'check-draft-eligibility', + name: 'Check Draft Eligibility', + description: 'Determines if a draft should be generated for this thread', + enabled: true, + condition: async (context) => { + return shouldGenerateDraft(context.thread, context.foundConnection); + }, + action: async (context) => { + console.log('[WORKFLOW_ENGINE] Thread eligible for draft generation', { + threadId: context.threadId, + connectionId: context.connectionId, + }); + return { eligible: true }; + }, + }, + { + id: 'analyze-email-intent', + name: 'Analyze Email Intent', + description: 'Analyzes the intent of the latest email in the thread', + enabled: true, + action: workflowFunctions.analyzeEmailIntent, + }, + { + id: 'validate-response-needed', + name: 'Validate Response Needed', + description: 'Checks if the email requires a response based on intent analysis', + enabled: true, + action: workflowFunctions.validateResponseNeeded, + }, + { + id: 'generate-draft-content', + name: 'Generate Draft Content', + description: 'Generates the draft email content using AI', + enabled: true, + action: workflowFunctions.generateAutomaticDraft, + errorHandling: 'continue', + }, + { + id: 'create-draft', + name: 'Create Draft', + description: 'Creates the draft in the email system', + enabled: true, + action: workflowFunctions.createDraft, + errorHandling: 'continue', + }, + ], + }; + + const vectorizationWorkflow: WorkflowDefinition = { + name: 'message-vectorization', + description: 'Vectorizes thread messages for search and analysis', + steps: [ + { + id: 'find-messages-to-vectorize', + name: 'Find Messages to Vectorize', + description: 'Identifies messages that need vectorization', + enabled: true, + action: workflowFunctions.findMessagesToVectorize, + }, + { + id: 'vectorize-messages', + name: 'Vectorize Messages', + description: 'Converts messages to vector embeddings', + enabled: true, + action: workflowFunctions.vectorizeMessages, + }, + { + id: 'upsert-embeddings', + name: 'Upsert Embeddings', + description: 'Saves vector embeddings to the database', + enabled: true, + action: workflowFunctions.upsertEmbeddings, + errorHandling: 'continue', + }, + ], + }; + + const threadSummaryWorkflow: WorkflowDefinition = { + name: 'thread-summary', + description: 'Generates and stores thread summaries', + steps: [ + { + id: 'check-existing-summary', + name: 'Check Existing Summary', + description: 'Checks if a thread summary already exists', + enabled: true, + action: workflowFunctions.checkExistingSummary, + }, + { + id: 'generate-thread-summary', + name: 'Generate Thread Summary', + description: 'Generates a summary of the thread', + enabled: true, + action: workflowFunctions.generateThreadSummary, + errorHandling: 'continue', + }, + { + id: 'upsert-thread-summary', + name: 'Upsert Thread Summary', + description: 'Saves thread summary to the database', + enabled: true, + action: workflowFunctions.upsertThreadSummary, + errorHandling: 'continue', + }, + ], + }; + + const labelGenerationWorkflow: WorkflowDefinition = { + name: 'label-generation', + description: 'Generates and applies labels to threads', + steps: [ + { + id: 'get-user-labels', + name: 'Get User Labels', + description: 'Retrieves user-defined labels', + enabled: true, + action: workflowFunctions.getUserLabels, + }, + { + id: 'generate-labels', + name: 'Generate Labels', + description: 'Generates appropriate labels for the thread', + enabled: true, + action: workflowFunctions.generateLabels, + errorHandling: 'continue', + }, + { + id: 'apply-labels', + name: 'Apply Labels', + description: 'Applies generated labels to the thread', + enabled: true, + action: workflowFunctions.applyLabels, + errorHandling: 'continue', + }, + ], + }; + + engine.registerWorkflow(autoDraftWorkflow); + engine.registerWorkflow(vectorizationWorkflow); + engine.registerWorkflow(threadSummaryWorkflow); + engine.registerWorkflow(labelGenerationWorkflow); + + return engine; +}; diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts new file mode 100644 index 0000000000..40f38a0f16 --- /dev/null +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -0,0 +1,548 @@ +import { + SummarizeMessage, + ThreadLabels, + ReSummarizeThread, + SummarizeThread, +} from '../lib/brain.fallback.prompts'; +import { analyzeEmailIntent, generateAutomaticDraft, shouldGenerateDraft } from './index'; +import { EPrompts, defaultLabels, type ParsedMessage } from '../types'; +import { getPrompt, getEmbeddingVector } from '../pipelines.effect'; +import { messageToXML, threadToXML } from './workflow-utils'; +import type { WorkflowContext } from './workflow-engine'; +import { getZeroAgent } from '../lib/server-utils'; +import { getPromptName } from '../pipelines'; +import { env } from 'cloudflare:workers'; +import { Effect } from 'effect'; + +export type WorkflowFunction = (context: WorkflowContext) => Promise; + +export const workflowFunctions: Record = { + shouldGenerateDraft: async (context) => { + return shouldGenerateDraft(context.thread, context.foundConnection); + }, + + analyzeEmailIntent: async (context) => { + if (!context.thread.messages || context.thread.messages.length === 0) { + throw new Error('Cannot analyze email intent: No messages in thread'); + } + const latestMessage = context.thread.messages[context.thread.messages.length - 1]; + const emailIntent = analyzeEmailIntent(latestMessage); + + console.log('[WORKFLOW_FUNCTIONS] Analyzed email intent:', { + threadId: context.threadId, + isQuestion: emailIntent.isQuestion, + isRequest: emailIntent.isRequest, + isMeeting: emailIntent.isMeeting, + isUrgent: emailIntent.isUrgent, + }); + + return emailIntent; + }, + + validateResponseNeeded: async (context) => { + const intentResult = context.results?.get('analyze-email-intent'); + if (!intentResult) { + throw new Error('Email intent analysis not available'); + } + + const requiresResponse = + intentResult.isQuestion || + intentResult.isRequest || + intentResult.isMeeting || + intentResult.isUrgent; + + if (!requiresResponse) { + console.log( + '[WORKFLOW_FUNCTIONS] Email does not require a response, skipping draft generation', + ); + return { requiresResponse: false }; + } + + return { requiresResponse: true }; + }, + + generateAutomaticDraft: async (context) => { + console.log('[WORKFLOW_FUNCTIONS] Generating automatic draft for thread:', context.threadId); + + const draftContent = await generateAutomaticDraft( + context.connectionId, + context.thread, + context.foundConnection, + ); + + if (!draftContent) { + throw new Error('Failed to generate draft content'); + } + + return { draftContent }; + }, + + createDraft: async (context) => { + const draftContentResult = context.results?.get('generate-draft-content'); + if (!draftContentResult?.draftContent) { + throw new Error('No draft content available'); + } + + const latestMessage = context.thread.messages[context.thread.messages.length - 1]; + const replyTo = latestMessage.sender?.email || ''; + if (!replyTo) { + throw new Error('Cannot create draft: No sender email in latest message'); + } + const cc = + latestMessage.cc + ?.map((r) => r.email) + .filter((email) => email && email !== context.foundConnection.email) || []; + + const originalSubject = latestMessage.subject || ''; + const replySubject = originalSubject.startsWith('Re: ') + ? originalSubject + : `Re: ${originalSubject}`; + + const draftData = { + to: replyTo, + cc: cc.join(', '), + bcc: '', + subject: replySubject, + message: draftContentResult.draftContent, + attachments: [], + id: null, + threadId: context.threadId, + fromEmail: context.foundConnection.email, + }; + + const agent = await getZeroAgent(context.connectionId); + const createdDraft = await agent.createDraft(draftData); + console.log('[WORKFLOW_FUNCTIONS] Created automatic draft:', { + threadId: context.threadId, + draftId: createdDraft?.id, + }); + + return { draftId: createdDraft?.id || null }; + }, + + findMessagesToVectorize: async (context) => { + console.log('[WORKFLOW_FUNCTIONS] Finding messages to vectorize'); + const messageIds = context.thread.messages.map((message) => message.id); + console.log('[WORKFLOW_FUNCTIONS] Found message IDs:', messageIds); + + const existingMessages = await env.VECTORIZE_MESSAGE.getByIds(messageIds); + console.log('[WORKFLOW_FUNCTIONS] Found existing messages:', existingMessages.length); + + const existingMessageIds = new Set(existingMessages.map((message: any) => message.id)); + const messagesToVectorize = context.thread.messages.filter( + (message) => !existingMessageIds.has(message.id), + ); + + console.log('[WORKFLOW_FUNCTIONS] Messages to vectorize:', messagesToVectorize.length); + return { messagesToVectorize, existingMessages }; + }, + + vectorizeMessages: async (context) => { + const vectorizeResult = context.results?.get('find-messages-to-vectorize'); + if (!vectorizeResult?.messagesToVectorize) { + console.log('[WORKFLOW_FUNCTIONS] No messages to vectorize, skipping'); + return { embeddings: [] }; + } + + const messagesToVectorize = vectorizeResult.messagesToVectorize; + console.log( + '[WORKFLOW_FUNCTIONS] Starting message vectorization for', + messagesToVectorize.length, + 'messages', + ); + + type VectorizedMessage = { + id: string; + metadata: { + connection: string; + thread: string; + summary: string; + }; + values: number[]; + }; + + const vectorizeSingleMessage = ( + message: ParsedMessage, + ): Effect.Effect => + Effect.tryPromise(async (): Promise => { + console.log('[WORKFLOW_FUNCTIONS] Converting message to XML:', message.id); + const prompt = await messageToXML(message); + if (!prompt) { + console.log('[WORKFLOW_FUNCTIONS] Message has no prompt, skipping:', message.id); + return null; + } + + const SummarizeMessagePrompt = await getPrompt( + getPromptName(message.connectionId ?? '', EPrompts.SummarizeMessage), + SummarizeMessage, + ); + + const messages = [ + { role: 'system', content: SummarizeMessagePrompt }, + { role: 'user', content: prompt }, + ]; + + const response = await env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', { + messages, + }); + + const summary = 'response' in response ? response.response : response; + if (!summary || typeof summary !== 'string') { + throw new Error(`Invalid summary response for message ${message.id}`); + } + + const embeddingVector = await getEmbeddingVector(summary); + if (!embeddingVector) { + throw new Error(`Message Embedding vector is null ${message.id}`); + } + + return { + id: message.id, + metadata: { + connection: message.connectionId ?? '', + thread: message.threadId ?? '', + summary, + }, + values: embeddingVector, + }; + }).pipe( + Effect.catchAll((error) => { + console.log('[WORKFLOW_FUNCTIONS] Failed to vectorize message:', { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + }); + return Effect.succeed(null); + }), + ); + + const vectorizeEffects: Effect.Effect[] = + messagesToVectorize.map(vectorizeSingleMessage); + + const program = Effect.all(vectorizeEffects, { concurrency: 3 }).pipe( + Effect.map((results) => { + const validResults = results.filter( + (result): result is VectorizedMessage => result !== null, + ); + console.log('[WORKFLOW_FUNCTIONS] Successfully vectorized messages:', validResults.length); + return { embeddings: validResults }; + }), + ); + + return Effect.runPromise(program); + }, + + upsertEmbeddings: async (context) => { + const vectorizeResult = context.results?.get('vectorize-messages'); + if (!vectorizeResult?.embeddings || vectorizeResult.embeddings.length === 0) { + console.log('[WORKFLOW_FUNCTIONS] No embeddings to upsert'); + return { upserted: 0 }; + } + + console.log( + '[WORKFLOW_FUNCTIONS] Upserting message vectors:', + vectorizeResult.embeddings.length, + ); + await env.VECTORIZE_MESSAGE.upsert(vectorizeResult.embeddings); + console.log('[WORKFLOW_FUNCTIONS] Successfully upserted message vectors'); + + return { upserted: vectorizeResult.embeddings.length }; + }, + + checkExistingSummary: async (context) => { + console.log('[WORKFLOW_FUNCTIONS] Getting existing thread summary for:', context.threadId); + const threadSummary = await env.VECTORIZE.getByIds([context.threadId.toString()]); + if (!threadSummary.length) { + console.log('[WORKFLOW_FUNCTIONS] No existing thread summary found'); + return { existingSummary: null }; + } + console.log('[WORKFLOW_FUNCTIONS] Found existing thread summary'); + + const metadata = threadSummary[0].metadata; + if (!metadata || typeof metadata !== 'object') { + console.warn('[WORKFLOW_FUNCTIONS] Invalid metadata structure, returning null'); + return { existingSummary: null }; + } + + const { summary, lastMsg } = metadata as any; + if (typeof summary !== 'string' || typeof lastMsg !== 'string') { + console.warn( + '[WORKFLOW_FUNCTIONS] Metadata missing required string properties (summary, lastMsg), returning null', + ); + return { existingSummary: null }; + } + + return { existingSummary: { summary, lastMsg } }; + }, + + generateThreadSummary: async (context) => { + const summaryResult = context.results?.get('check-existing-summary'); + const existingSummary = summaryResult?.existingSummary; + + const newestMessage = context.thread.messages[context.thread.messages.length - 1]; + if (existingSummary && existingSummary.lastMsg === newestMessage?.id) { + console.log( + '[WORKFLOW_FUNCTIONS] No new messages since last processing, skipping AI processing', + ); + return { summary: existingSummary.summary }; + } + + console.log('[WORKFLOW_FUNCTIONS] Generating final thread summary'); + if (existingSummary) { + console.log('[WORKFLOW_FUNCTIONS] Using existing summary as context'); + const summary = await summarizeThread( + context.connectionId, + context.thread.messages, + existingSummary.summary, + ); + return { summary }; + } else { + console.log('[WORKFLOW_FUNCTIONS] Generating new summary without context'); + const summary = await summarizeThread( + context.connectionId, + context.thread.messages, + undefined, + ); + return { summary }; + } + }, + + upsertThreadSummary: async (context) => { + const summaryResult = context.results?.get('generate-thread-summary'); + if (!summaryResult?.summary) { + console.log('[WORKFLOW_FUNCTIONS] No summary generated for thread'); + return { upserted: false }; + } + + const embeddingVector = await getEmbeddingVector(summaryResult.summary); + if (!embeddingVector) { + console.log('[WORKFLOW_FUNCTIONS] Thread Embedding vector is null, skipping vector upsert'); + return { upserted: false }; + } + + console.log('[WORKFLOW_FUNCTIONS] Upserting thread vector'); + const newestMessage = context.thread.messages[context.thread.messages.length - 1]; + await env.VECTORIZE.upsert([ + { + id: context.threadId.toString(), + metadata: { + connection: context.connectionId.toString(), + thread: context.threadId.toString(), + summary: summaryResult.summary, + lastMsg: newestMessage?.id, + }, + values: embeddingVector, + }, + ]); + console.log('[WORKFLOW_FUNCTIONS] Successfully upserted thread vector'); + + return { upserted: true }; + }, + + getUserLabels: async (context) => { + try { + const agent = await getZeroAgent(context.connectionId); + const userAccountLabels = await agent.getUserLabels(); + return { userAccountLabels }; + } catch (error) { + console.error('[WORKFLOW_FUNCTIONS] Error in getUserLabels:', error); + return { userAccountLabels: [] }; + } + }, + + generateLabels: async (context) => { + const summaryResult = context.results?.get('generate-thread-summary'); + if (!summaryResult?.summary) { + console.log('[WORKFLOW_FUNCTIONS] No summary available for label generation'); + return { labels: [] }; + } + + console.log('[WORKFLOW_FUNCTIONS] Getting user topics for connection:', context.connectionId); + let userLabels: { name: string; usecase: string }[] = []; + try { + const agent = await getZeroAgent(context.connectionId); + const userTopics = await agent.getUserTopics(); + if (userTopics.length > 0) { + userLabels = userTopics.map((topic: any) => ({ + name: topic.topic, + usecase: topic.usecase, + })); + console.log('[WORKFLOW_FUNCTIONS] Using user topics as labels:', userLabels); + } else { + console.log('[WORKFLOW_FUNCTIONS] No user topics found, using defaults'); + userLabels = defaultLabels; + } + } catch (error) { + console.log('[WORKFLOW_FUNCTIONS] Failed to get user topics, using defaults:', error); + userLabels = defaultLabels; + } + + console.log('[WORKFLOW_FUNCTIONS] Generating labels for thread:', { + userLabels, + threadId: context.threadId, + threadLabels: context.thread.labels, + }); + + const labelsResponse: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + messages: [ + { role: 'system', content: ThreadLabels(userLabels, context.thread.labels) }, + { role: 'user', content: summaryResult.summary }, + ], + }); + + if (labelsResponse?.response?.replaceAll('!', '').trim()?.length) { + console.log('[WORKFLOW_FUNCTIONS] Labels generated:', labelsResponse.response); + const labels: string[] = labelsResponse?.response + ?.split(',') + .map((e: string) => e.trim()) + .filter((e: string) => e.length > 0) + .filter((e: string) => + userLabels.find((label) => label.name.toLowerCase() === e.toLowerCase()), + ); + return { labels, userLabelsUsed: userLabels }; + } else { + console.log('[WORKFLOW_FUNCTIONS] No labels generated'); + return { labels: [], userLabelsUsed: userLabels }; + } + }, + + applyLabels: async (context) => { + const labelsResult = context.results?.get('generate-labels'); + const userLabelsResult = context.results?.get('get-user-labels'); + + if (!labelsResult?.labels || labelsResult.labels.length === 0) { + console.log('[WORKFLOW_FUNCTIONS] No labels to apply'); + return { applied: false }; + } + + if (!userLabelsResult?.userAccountLabels) { + console.log('[WORKFLOW_FUNCTIONS] No user account labels available'); + return { applied: false }; + } + + const userAccountLabels = userLabelsResult.userAccountLabels; + const generatedLabels = labelsResult.labels; + + console.log('[WORKFLOW_FUNCTIONS] Modifying thread labels:', generatedLabels); + + const agent = await getZeroAgent(context.connectionId); + + const validLabelIds = generatedLabels + .map((name: string) => { + const foundLabel = userAccountLabels.find( + (label: { name: string; id: string }) => label.name.toLowerCase() === name.toLowerCase(), + ); + return foundLabel?.id; + }) + .filter((id: string | undefined): id is string => id !== undefined && id !== ''); + + if (validLabelIds.length > 0) { + const currentLabelIds = context.thread.labels?.map((l: { id: string }) => l.id) || []; + const labelsToAdd = validLabelIds.filter((id: string) => !currentLabelIds.includes(id)); + + const aiManagedLabelNames = new Set( + (labelsResult.userLabelsUsed || []).map((topic: { name: string }) => + topic.name.toLowerCase(), + ), + ); + + const aiManagedLabelIds = new Set( + userAccountLabels + .filter((label: { name: string }) => aiManagedLabelNames.has(label.name.toLowerCase())) + .map((label: { id: string }) => label.id), + ); + + const labelsToRemove = currentLabelIds.filter( + (id: string) => aiManagedLabelIds.has(id) && !validLabelIds.includes(id), + ); + + if (labelsToAdd.length > 0 || labelsToRemove.length > 0) { + console.log('[WORKFLOW_FUNCTIONS] Applying label changes:', { + add: labelsToAdd, + remove: labelsToRemove, + }); + await agent.modifyThreadLabelsInDB( + context.threadId.toString(), + labelsToAdd, + labelsToRemove, + ); + console.log('[WORKFLOW_FUNCTIONS] Successfully modified thread labels'); + return { applied: true, added: labelsToAdd.length, removed: labelsToRemove.length }; + } else { + console.log('[WORKFLOW_FUNCTIONS] No label changes needed - labels already match'); + return { applied: false }; + } + } + + console.log('[WORKFLOW_FUNCTIONS] No valid labels found in user account'); + return { applied: false }; + }, +}; + +// Helper function for thread summarization +const summarizeThread = async ( + connectionId: string, + messages: ParsedMessage[], + existingSummary?: string, +): Promise => { + try { + if (!messages || !Array.isArray(messages) || messages.length === 0) { + console.log('[SUMMARIZE_THREAD] No messages provided for summarization'); + return null; + } + + if (!connectionId || typeof connectionId !== 'string') { + console.log('[SUMMARIZE_THREAD] Invalid connection ID provided'); + return null; + } + + const prompt = await threadToXML(messages, existingSummary); + if (!prompt) { + console.log('[SUMMARIZE_THREAD] Failed to generate thread XML'); + return null; + } + + if (existingSummary) { + const ReSummarizeThreadPrompt = await getPrompt( + getPromptName(connectionId, EPrompts.ReSummarizeThread), + ReSummarizeThread, + ); + const promptMessages = [ + { role: 'system', content: ReSummarizeThreadPrompt }, + { + role: 'user', + content: prompt, + }, + ]; + const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + messages: promptMessages, + }); + const summary = response?.response; + return typeof summary === 'string' ? summary : null; + } else { + const SummarizeThreadPrompt = await getPrompt( + getPromptName(connectionId, EPrompts.SummarizeThread), + SummarizeThread, + ); + const promptMessages = [ + { role: 'system', content: SummarizeThreadPrompt }, + { + role: 'user', + content: prompt, + }, + ]; + const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + messages: promptMessages, + }); + const summary = response?.response; + return typeof summary === 'string' ? summary : null; + } + } catch (error) { + console.log('[SUMMARIZE_THREAD] Failed to summarize thread:', { + connectionId, + messageCount: messages?.length || 0, + hasExistingSummary: !!existingSummary, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +}; diff --git a/apps/server/src/thread-workflow-utils/workflow-utils.ts b/apps/server/src/thread-workflow-utils/workflow-utils.ts new file mode 100644 index 0000000000..ed075e76c7 --- /dev/null +++ b/apps/server/src/thread-workflow-utils/workflow-utils.ts @@ -0,0 +1,122 @@ +import type { ParsedMessage } from '../types'; +import * as cheerio from 'cheerio'; + +export async function htmlToText(decodedBody: string): Promise { + try { + if (!decodedBody || typeof decodedBody !== 'string') { + return ''; + } + const $ = cheerio.load(decodedBody); + $('script').remove(); + $('style').remove(); + return $('body') + .text() + .replace(/\r?\n|\r/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } catch (error) { + console.error('Error extracting text from HTML:', error); + return ''; + } +} + +export const escapeXml = (text: string): string => { + if (!text) return ''; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +export const messageToXML = async (message: ParsedMessage) => { + try { + if (!message.decodedBody) return null; + const body = await htmlToText(message.decodedBody || ''); + if (!body || body.length < 10) { + return null; + } + + const safeSenderName = escapeXml(message.sender?.name || 'Unknown'); + const safeSubject = escapeXml(message.subject || ''); + const safeDate = escapeXml(message.receivedOn || ''); + + const toElements = (message.to || []) + .map((r: any) => `${escapeXml(r?.email || '')}`) + .join(''); + const ccElements = (message.cc || []) + .map((r: any) => `${escapeXml(r?.email || '')}`) + .join(''); + + return ` + + ${safeSenderName} + ${toElements} + ${ccElements} + ${safeDate} + ${safeSubject} + ${escapeXml(body)} + + `; + } catch (error) { + console.log('[MESSAGE_TO_XML] Failed to convert message to XML:', { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +}; + +export const getParticipants = (messages: ParsedMessage[]) => { + const participants = new Map(); + + const setIfUnset = (sender: any) => { + if (!sender?.email) return; + if (!participants.has(sender.email)) { + participants.set(sender.email, { + name: sender.name, + email: sender.email, + }); + } + }; + + messages.forEach((message) => { + setIfUnset(message.sender); + (message.to || []).forEach(setIfUnset); + (message.cc || []).forEach(setIfUnset); + }); + + return Array.from(participants.values()); +}; + +export const threadToXML = async (messages: ParsedMessage[], existingSummary?: string) => { + const participants = getParticipants(messages); + const title = messages[0]?.subject || 'No Subject'; + const subject = messages[0]?.subject || 'No Subject'; + + const participantsXML = participants + .map((p) => { + const displayName = escapeXml(p.name || p.email); + const emailTag = p.name ? `< ${escapeXml(p.email)} >` : ''; + return `${displayName} ${emailTag}`; + }) + .join(''); + + const messagesXML = await Promise.all(messages.map(messageToXML)); + const validMessagesXML = messagesXML.filter(Boolean).join(''); + + return ` + + ${escapeXml(title)} + ${escapeXml(subject)} + + ${participantsXML} + + ${existingSummary ? `${escapeXml(existingSummary)}` : ''} + + ${validMessagesXML} + + + `; +}; diff --git a/apps/server/src/trpc/routes/ai/compose.ts b/apps/server/src/trpc/routes/ai/compose.ts index 03cd0bd1f2..7ce7fef77f 100644 --- a/apps/server/src/trpc/routes/ai/compose.ts +++ b/apps/server/src/trpc/routes/ai/compose.ts @@ -2,6 +2,7 @@ import { getWritingStyleMatrixForConnectionId, type WritingStyleMatrix, } from '../../../services/writing-style-service'; +import { escapeXml } from '../../../thread-workflow-utils/workflow-utils'; import { StyledEmailAssistantSystemPrompt } from '../../../lib/prompts'; import { webSearch } from '../../../routes/agent/tools'; import { activeConnectionProcedure } from '../../trpc'; @@ -191,14 +192,6 @@ const MessagePrompt = ({ return parts.join('\n'); }; -const escapeXml = (s: string) => - s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - const EmailAssistantPrompt = ({ currentSubject, recipients, diff --git a/apps/server/src/trpc/routes/mail.ts b/apps/server/src/trpc/routes/mail.ts index fee41e77cd..f5ec9d4828 100644 --- a/apps/server/src/trpc/routes/mail.ts +++ b/apps/server/src/trpc/routes/mail.ts @@ -20,16 +20,13 @@ const senderSchema = z.object({ email: z.string(), }); -const FOLDER_TO_LABEL_MAP: Record = { - inbox: 'INBOX', - sent: 'SENT', - draft: 'DRAFT', - spam: 'SPAM', - trash: 'TRASH', -}; - const getFolderLabelId = (folder: string) => { - return FOLDER_TO_LABEL_MAP[folder]; + // Handle special cases first + if (folder === 'bin') return 'TRASH'; + if (folder === 'archive') return ''; // Archive doesn't have a specific label + + // For other folders, convert to uppercase (same as database method) + return folder.toUpperCase(); }; export const mailRouter = router({ @@ -92,29 +89,17 @@ export const mailRouter = router({ let threadsResponse: IGetThreadsResponse; - if (q) { - console.debug('[listThreads] Performing search with query:', q); - threadsResponse = await agent.rawListThreads({ - folder, - query: q, - maxResults, - labelIds, - pageToken: cursor, - }); - console.debug('[listThreads] Search result:', threadsResponse); - } else { - const folderLabelId = getFolderLabelId(folder); - const labelIdsToUse = folderLabelId ? [...labelIds, folderLabelId] : labelIds; - console.debug('[listThreads] Listing with labelIds:', labelIdsToUse, 'for folder:', folder); - - threadsResponse = await agent.listThreads({ - folder, - labelIds: labelIdsToUse, - maxResults, - pageToken: cursor, - }); - console.debug('[listThreads] List result:', threadsResponse); - } + // Apply folder-to-label mapping when no search query is provided + const folderLabelId = getFolderLabelId(folder); + const effectiveLabelIds = q ? labelIds : [...labelIds, folderLabelId].filter(Boolean); + + threadsResponse = await agent.rawListThreads({ + folder, + query: q, + maxResults, + labelIds: effectiveLabelIds, + pageToken: cursor, + }); if (folder === FOLDERS.SNOOZED) { const nowTs = Date.now(); From d043eaa84a6642739e3c93fad77f6634bc99407a Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:30:43 -0700 Subject: [PATCH 12/48] Refactor workflow processing to use durable objects for better concurrency (#1853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Refactored workflow processing with Durable Objects ## Description Refactored the workflow processing system to use Durable Objects for better concurrency and reliability. Created a new `WorkflowRunner` Durable Object that encapsulates the workflow execution logic, replacing the previous function-based approach. This change improves thread processing by providing better isolation and state management. The implementation includes: - A new `WorkflowRunner` Durable Object that handles main, zero, and thread workflows - Updated thread queue processing to use the Durable Object - Enhanced workflow engine with execution tracking to prevent duplicate processing - Added cleanup steps to properly manage workflow execution state ## Type of Change - ✨ New feature (non-breaking change which adds functionality) - ⚡ Performance improvement ## Areas Affected - [x] Email Integration (Gmail, IMAP, etc.) - [x] Data Storage/Management - [x] Deployment/Infrastructure ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have updated the documentation ## Additional Notes This change should significantly improve the reliability of thread processing by preventing race conditions and providing better isolation between workflow executions. The Durable Object approach also allows for better scaling as each workflow execution gets its own isolated environment. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ ## Summary by CodeRabbit * **New Features** * Introduced a new workflow execution engine with the `WorkflowRunner` class for improved workflow management and error handling. * Added workflow execution tracking to prevent duplicate processing of threads, with automatic cleanup for re-execution. * Added new workflow functions to check and clean up workflow execution state. * **Improvements** * Enhanced workflow step execution logic with conditional and batched processing for better reliability and efficiency. * Updated configuration to support the new workflow runner across all environments. * **Chores** * Refactored and reorganized workflow-related code for better maintainability and structure. --- apps/server/src/main.ts | 17 +- apps/server/src/pipelines.effect.ts | 639 ----------------- apps/server/src/pipelines.ts | 649 +++++++++++++++++- .../thread-workflow-utils/workflow-engine.ts | 83 +++ .../workflow-functions.ts | 56 +- apps/server/wrangler.jsonc | 24 + 6 files changed, 802 insertions(+), 666 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7b90870260..1fc263736b 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -19,7 +19,6 @@ import { EProviders, type ISubscribeBatch, type IThreadBatch } from './types'; import { oAuthDiscoveryMetadata } from 'better-auth/plugins'; import { getZeroDB, verifyToken } from './lib/server-utils'; import { eq, and, desc, asc, inArray } from 'drizzle-orm'; -import { EWorkflowType, runWorkflow } from './pipelines'; import { ThinkingMCP } from './lib/sequential-thinking'; import { ZeroAgent, ZeroDriver } from './routes/agent'; import { contextStorage } from 'hono/context-storage'; @@ -31,6 +30,7 @@ import { trpcServer } from '@hono/trpc-server'; import { agentsMiddleware } from 'hono-agents'; import { ZeroMCP } from './routes/agent/mcp'; import { publicRouter } from './routes/auth'; +import { WorkflowRunner } from './pipelines'; import { autumnApi } from './routes/autumn'; import type { HonoContext } from './ctx'; import { createDb, type DB } from './db'; @@ -39,7 +39,6 @@ import { aiRouter } from './routes/ai'; import { Autumn } from 'autumn-js'; import { appRouter } from './trpc'; import { cors } from 'hono/cors'; -import { Effect } from 'effect'; import { Hono } from 'hono'; @@ -753,14 +752,14 @@ export default class extends WorkerEntrypoint { const providerId = msg.body.providerId; const historyId = msg.body.historyId; const subscriptionName = msg.body.subscriptionName; - const workflow = runWorkflow(EWorkflowType.MAIN, { - providerId, - historyId, - subscriptionName, - }); try { - const result = await Effect.runPromise(workflow); + const workflowRunner = env.WORKFLOW_RUNNER.get(env.WORKFLOW_RUNNER.newUniqueId()); + const result = await workflowRunner.runMainWorkflow({ + providerId, + historyId, + subscriptionName, + }); console.log('[THREAD_QUEUE] result', result); } catch (error) { console.error('Error running workflow', error); @@ -864,4 +863,4 @@ export default class extends WorkerEntrypoint { } } -export { ZeroAgent, ZeroMCP, ZeroDB, ZeroDriver, ThinkingMCP }; +export { ZeroAgent, ZeroMCP, ZeroDB, ZeroDriver, ThinkingMCP, WorkflowRunner }; diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index 848fd64ea9..58430fecf3 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -11,17 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createDefaultWorkflows } from './thread-workflow-utils/workflow-engine'; -import { getServiceAccount } from './lib/factories/google-subscription.factory'; -import { EWorkflowType, runWorkflow } from './pipelines'; -import { getZeroAgent } from './lib/server-utils'; -import { type gmail_v1 } from '@googleapis/gmail'; -import { Effect, Console, Logger } from 'effect'; import { env } from 'cloudflare:workers'; -import { connection } from './db/schema'; -import { EProviders } from './types'; -import { eq } from 'drizzle-orm'; -import { createDb } from './db'; const showLogs = true; @@ -34,641 +24,12 @@ const log = (message: string, ...args: any[]) => { }; // Configure pretty logger to stderr -export const loggerLayer = Logger.add(Logger.prettyLogger({ stderr: true })); - -const isValidUUID = (str: string): boolean => { - const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return regex.test(str); -}; - -// Define the workflow parameters type -type MainWorkflowParams = { - providerId: string; - historyId: string; - subscriptionName: string; -}; - -// Define error types -type MainWorkflowError = - | { _tag: 'MissingEnvironmentVariable'; variable: string } - | { _tag: 'InvalidSubscriptionName'; subscriptionName: string } - | { _tag: 'InvalidConnectionId'; connectionId: string } - | { _tag: 'UnsupportedProvider'; providerId: string } - | { _tag: 'WorkflowCreationFailed'; error: unknown }; - -const validateArguments = ( - params: MainWorkflowParams, - serviceAccount: { project_id: string }, -): Effect.Effect => - Effect.gen(function* () { - yield* Console.log('[MAIN_WORKFLOW] Validating arguments'); - const regex = new RegExp( - `projects/${serviceAccount.project_id}/subscriptions/notifications__([a-z0-9-]+)`, - ); - const match = params.subscriptionName.toString().match(regex); - if (!match) { - yield* Console.log('[MAIN_WORKFLOW] Invalid subscription name:', params.subscriptionName); - return yield* Effect.fail({ - _tag: 'InvalidSubscriptionName' as const, - subscriptionName: params.subscriptionName, - }); - } - const [, connectionId] = match; - yield* Console.log('[MAIN_WORKFLOW] Extracted connectionId:', connectionId); - return connectionId; - }); - -/** - * This function runs the main workflow. The main workflow is responsible for processing incoming messages from a Pub/Sub subscription and passing them to the appropriate pipeline. - * It validates the subscription name and extracts the connection ID. - * @param params - * @returns - */ -export const runMainWorkflow = ( - params: MainWorkflowParams, -): Effect.Effect => - Effect.gen(function* () { - yield* Console.log('[MAIN_WORKFLOW] Starting workflow with payload:', params); - - const { providerId, historyId } = params; - - const serviceAccount = getServiceAccount(); - - const connectionId = yield* validateArguments(params, serviceAccount); - - if (!isValidUUID(connectionId)) { - yield* Console.log('[MAIN_WORKFLOW] Invalid connection id format:', connectionId); - return yield* Effect.fail({ - _tag: 'InvalidConnectionId' as const, - connectionId, - }); - } - - const previousHistoryId = yield* Effect.tryPromise({ - try: () => env.gmail_history_id.get(connectionId), - catch: () => ({ _tag: 'WorkflowCreationFailed' as const, error: 'Failed to get history ID' }), - }).pipe(Effect.orElse(() => Effect.succeed(null))); - - if (providerId === EProviders.google) { - yield* Console.log('[MAIN_WORKFLOW] Processing Google provider workflow'); - yield* Console.log('[MAIN_WORKFLOW] Previous history ID:', previousHistoryId); - - const zeroWorkflowParams = { - connectionId, - historyId: previousHistoryId || historyId, - nextHistoryId: historyId, - }; - - const result = yield* runWorkflow(EWorkflowType.ZERO, zeroWorkflowParams).pipe( - Effect.mapError( - (error): MainWorkflowError => ({ _tag: 'WorkflowCreationFailed' as const, error }), - ), - ); - - yield* Console.log('[MAIN_WORKFLOW] Zero workflow result:', result); - } else { - yield* Console.log('[MAIN_WORKFLOW] Unsupported provider:', providerId); - return yield* Effect.fail({ - _tag: 'UnsupportedProvider' as const, - providerId, - }); - } - - yield* Console.log('[MAIN_WORKFLOW] Workflow completed successfully'); - return 'Workflow completed successfully'; - }).pipe( - Effect.tapError((error) => Console.log('[MAIN_WORKFLOW] Error in workflow:', error)), - Effect.provide(loggerLayer), - ); - -// Define the ZeroWorkflow parameters type -type ZeroWorkflowParams = { - connectionId: string; - historyId: string; - nextHistoryId: string; -}; - -// Define error types for ZeroWorkflow -type ZeroWorkflowError = - | { _tag: 'HistoryAlreadyProcessing'; connectionId: string; historyId: string } - | { _tag: 'ConnectionNotFound'; connectionId: string } - | { _tag: 'ConnectionNotAuthorized'; connectionId: string } - | { _tag: 'HistoryNotFound'; historyId: string; connectionId: string } - | { _tag: 'UnsupportedProvider'; providerId: string } - | { _tag: 'DatabaseError'; error: unknown } - | { _tag: 'GmailApiError'; error: unknown } - | { _tag: 'WorkflowCreationFailed'; error: unknown } - | { _tag: 'LabelModificationFailed'; error: unknown; threadId: string }; - -export const runZeroWorkflow = ( - params: ZeroWorkflowParams, -): Effect.Effect => - Effect.gen(function* () { - yield* Console.log('[ZERO_WORKFLOW] Starting workflow with payload:', params); - const { connectionId, historyId, nextHistoryId } = params; - - const historyProcessingKey = `history_${connectionId}__${historyId}`; - - // Atomic lock acquisition to prevent race conditions - const lockAcquired = yield* Effect.tryPromise({ - try: async () => { - const response = await env.gmail_processing_threads.put(historyProcessingKey, 'true', { - expirationTtl: 3600, - }); - return response !== null; // null means key already existed - }, - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }); - - if (!lockAcquired) { - yield* Console.log('[ZERO_WORKFLOW] History already being processed:', { - connectionId, - historyId, - }); - return yield* Effect.fail({ - _tag: 'HistoryAlreadyProcessing' as const, - connectionId, - historyId, - }); - } - - yield* Console.log( - '[ZERO_WORKFLOW] Acquired processing lock for history:', - historyProcessingKey, - ); - - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); - - const foundConnection = yield* Effect.tryPromise({ - try: async () => { - console.log('[ZERO_WORKFLOW] Finding connection:', connectionId); - const [foundConnection] = await db - .select() - .from(connection) - .where(eq(connection.id, connectionId.toString())); - await conn.end(); - if (!foundConnection) { - throw new Error(`Connection not found ${connectionId}`); - } - if (!foundConnection.accessToken || !foundConnection.refreshToken) { - throw new Error(`Connection is not authorized ${connectionId}`); - } - console.log('[ZERO_WORKFLOW] Found connection:', foundConnection.id); - return foundConnection; - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - yield* Effect.tryPromise({ - try: async () => conn.end(), - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - const agent = yield* Effect.tryPromise({ - try: async () => await getZeroAgent(foundConnection.id), - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - if (foundConnection.providerId === EProviders.google) { - yield* Console.log('[ZERO_WORKFLOW] Processing Google provider workflow'); - - const history = yield* Effect.tryPromise({ - try: async () => { - console.log('[ZERO_WORKFLOW] Getting Gmail history with ID:', historyId); - const { history } = (await agent.listHistory(historyId.toString())) as { - history: gmail_v1.Schema$History[]; - }; - console.log('[ZERO_WORKFLOW] Found history entries:', history); - return history; - }, - catch: (error) => ({ _tag: 'GmailApiError' as const, error }), - }); - - yield* Effect.tryPromise({ - try: () => { - console.log('[ZERO_WORKFLOW] Updating next history ID:', nextHistoryId); - return env.gmail_history_id.put(connectionId.toString(), nextHistoryId.toString()); - }, - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }); - - if (!history.length) { - yield* Console.log('[ZERO_WORKFLOW] No history found, skipping'); - return 'No history found'; - } - - // Extract thread IDs from history and track label changes - const threadsAdded = new Set(); - const threadLabelChanges = new Map< - string, - { addLabels: Set; removeLabels: Set } - >(); - - // Optimal single-pass functional processing - const processLabelChange = ( - labelChange: { message?: gmail_v1.Schema$Message; labelIds?: string[] | null }, - isAddition: boolean, - ) => { - const threadId = labelChange.message?.threadId; - if (!threadId || !labelChange.labelIds?.length) return; - - let changes = threadLabelChanges.get(threadId); - if (!changes) { - changes = { addLabels: new Set(), removeLabels: new Set() }; - threadLabelChanges.set(threadId, changes); - } - - const targetSet = isAddition ? changes.addLabels : changes.removeLabels; - labelChange.labelIds.forEach((labelId) => targetSet.add(labelId)); - }; - - history.forEach((historyItem) => { - // Extract thread IDs from messages - historyItem.messagesAdded?.forEach((msg) => { - if (msg.message?.threadId) { - threadsAdded.add(msg.message.threadId); - } - }); - - // Process label changes using shared helper - historyItem.labelsAdded?.forEach((labelAdded) => processLabelChange(labelAdded, true)); - historyItem.labelsRemoved?.forEach((labelRemoved) => - processLabelChange(labelRemoved, false), - ); - }); - - yield* Console.log( - '[ZERO_WORKFLOW] Found unique thread IDs:', - Array.from(threadLabelChanges.keys()), - Array.from(threadsAdded), - ); - - if (threadsAdded.size > 0) { - const threadWorkflowParams = Array.from(threadsAdded); - - // Sync threads with proper error handling - use allSuccesses to collect successful syncs - const syncResults = yield* Effect.allSuccesses( - threadWorkflowParams.map((threadId) => - Effect.tryPromise({ - try: async () => { - const result = await agent.syncThread({ threadId }); - console.log(`[ZERO_WORKFLOW] Successfully synced thread ${threadId}`); - return { threadId, result }; - }, - catch: (error) => { - console.error(`[ZERO_WORKFLOW] Failed to sync thread ${threadId}:`, error); - // Let this effect fail so allSuccesses will exclude it - throw new Error( - `Failed to sync thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`, - ); - }, - }), - ), - { concurrency: 1 }, // Limit concurrency to avoid rate limits - ); - - const syncedCount = syncResults.length; - const failedCount = threadWorkflowParams.length - syncedCount; - - if (failedCount > 0) { - yield* Console.log( - `[ZERO_WORKFLOW] Warning: ${failedCount}/${threadWorkflowParams.length} thread syncs failed. Successfully synced: ${syncedCount}`, - ); - // Continue with processing - sync failures shouldn't stop the entire workflow - // The thread processing will continue with whatever data is available - } else { - yield* Console.log(`[ZERO_WORKFLOW] Successfully synced all ${syncedCount} threads`); - } - - yield* Console.log('[ZERO_WORKFLOW] Synced threads:', syncResults); - - // Run thread workflow for each successfully synced thread - if (syncedCount > 0) { - yield* Effect.tryPromise({ - try: () => agent.reloadFolder('inbox'), - catch: (error) => ({ _tag: 'GmailApiError' as const, error }), - }).pipe( - Effect.tap(() => Console.log('[ZERO_WORKFLOW] Successfully reloaded inbox folder')), - Effect.orElse(() => - Effect.gen(function* () { - yield* Console.log('[ZERO_WORKFLOW] Failed to reload inbox folder'); - return undefined; - }), - ), - ); - - yield* Console.log( - `[ZERO_WORKFLOW] Running thread workflows for ${syncedCount} synced threads`, - ); - - const threadWorkflowResults = yield* Effect.allSuccesses( - syncResults.map(({ threadId }) => - runWorkflow(EWorkflowType.THREAD, { - connectionId, - threadId, - providerId: foundConnection.providerId, - }).pipe( - Effect.tap(() => - Console.log(`[ZERO_WORKFLOW] Successfully ran thread workflow for ${threadId}`), - ), - Effect.tapError((error) => - Console.log( - `[ZERO_WORKFLOW] Failed to run thread workflow for ${threadId}:`, - error, - ), - ), - ), - ), - { concurrency: 1 }, // Limit concurrency to avoid overwhelming the system - ); - - const threadWorkflowSuccessCount = threadWorkflowResults.length; - const threadWorkflowFailedCount = syncedCount - threadWorkflowSuccessCount; - - if (threadWorkflowFailedCount > 0) { - yield* Console.log( - `[ZERO_WORKFLOW] Warning: ${threadWorkflowFailedCount}/${syncedCount} thread workflows failed. Successfully processed: ${threadWorkflowSuccessCount}`, - ); - } else { - yield* Console.log( - `[ZERO_WORKFLOW] Successfully ran all ${threadWorkflowSuccessCount} thread workflows`, - ); - } - } - } - - // Process label changes for threads - if (threadLabelChanges.size > 0) { - yield* Console.log( - `[ZERO_WORKFLOW] Processing label changes for ${threadLabelChanges.size} threads`, - ); - - // Process each thread's label changes - for (const [threadId, changes] of threadLabelChanges) { - const addLabels = Array.from(changes.addLabels); - const removeLabels = Array.from(changes.removeLabels); - - // Only call if there are actual changes to make - if (addLabels.length > 0 || removeLabels.length > 0) { - yield* Console.log( - `[ZERO_WORKFLOW] Modifying labels for thread ${threadId}: +${addLabels.length} -${removeLabels.length}`, - ); - yield* Effect.tryPromise({ - try: () => agent.modifyThreadLabelsInDB(threadId, addLabels, removeLabels), - catch: (error) => ({ _tag: 'LabelModificationFailed' as const, error, threadId }), - }).pipe( - Effect.orElse(() => - Effect.gen(function* () { - yield* Console.log( - `[ZERO_WORKFLOW] Failed to modify labels for thread ${threadId}`, - ); - return undefined; - }), - ), - ); - } - } - - yield* Console.log('[ZERO_WORKFLOW] Completed label modifications'); - } else { - yield* Console.log('[ZERO_WORKFLOW] No threads with label changes to process'); - } - - // Clean up processing flag - yield* Effect.tryPromise({ - try: () => { - console.log( - '[ZERO_WORKFLOW] Clearing processing flag for history:', - historyProcessingKey, - ); - return env.gmail_processing_threads.delete(historyProcessingKey); - }, - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }).pipe(Effect.orElse(() => Effect.succeed(null))); - - yield* Console.log('[ZERO_WORKFLOW] Processing complete'); - return 'Zero workflow completed successfully'; - } else { - yield* Console.log('[ZERO_WORKFLOW] Unsupported provider:', foundConnection.providerId); - return yield* Effect.fail({ - _tag: 'UnsupportedProvider' as const, - providerId: foundConnection.providerId, - }); - } - }).pipe( - Effect.tapError((error) => Console.log('[ZERO_WORKFLOW] Error in workflow:', error)), - Effect.catchAll((error) => { - // Clean up processing flag on error - return Effect.tryPromise({ - try: () => { - console.log( - '[ZERO_WORKFLOW] Clearing processing flag for history after error:', - `history_${params.connectionId}__${params.historyId}`, - ); - return env.gmail_processing_threads.delete( - `history_${params.connectionId}__${params.historyId}`, - ); - }, - catch: () => ({ - _tag: 'WorkflowCreationFailed' as const, - error: 'Failed to cleanup processing flag', - }), - }).pipe( - Effect.orElse(() => Effect.succeed(null)), - Effect.flatMap(() => Effect.fail(error)), - ); - }), - Effect.provide(loggerLayer), - ); - -// Define the ThreadWorkflow parameters type -type ThreadWorkflowParams = { - connectionId: string; - threadId: string; - providerId: string; -}; - -// Define error types for ThreadWorkflow -type ThreadWorkflowError = - | { _tag: 'ConnectionNotFound'; connectionId: string } - | { _tag: 'ConnectionNotAuthorized'; connectionId: string } - | { _tag: 'ThreadNotFound'; threadId: string } - | { _tag: 'UnsupportedProvider'; providerId: string } - | { _tag: 'DatabaseError'; error: unknown } - | { _tag: 'GmailApiError'; error: unknown } - | { _tag: 'VectorizationError'; error: unknown } - | { _tag: 'WorkflowCreationFailed'; error: unknown }; /** * Runs the main workflow for processing a thread. The workflow is responsible for processing incoming messages from a Pub/Sub subscription and passing them to the appropriate pipeline. * @param params * @returns */ -export const runThreadWorkflow = ( - params: ThreadWorkflowParams, -): Effect.Effect => - Effect.gen(function* () { - yield* Console.log('[THREAD_WORKFLOW] Starting workflow with payload:', params); - const { connectionId, threadId, providerId } = params; - - if (providerId === EProviders.google) { - yield* Console.log('[THREAD_WORKFLOW] Processing Google provider workflow'); - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); - - const foundConnection = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Finding connection:', connectionId); - const [foundConnection] = await db - .select() - .from(connection) - .where(eq(connection.id, connectionId.toString())); - if (!foundConnection) { - throw new Error(`Connection not found ${connectionId}`); - } - if (!foundConnection.accessToken || !foundConnection.refreshToken) { - throw new Error(`Connection is not authorized ${connectionId}`); - } - console.log('[THREAD_WORKFLOW] Found connection:', foundConnection.id); - return foundConnection; - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - yield* Effect.tryPromise({ - try: async () => conn.end(), - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - const agent = yield* Effect.tryPromise({ - try: async () => await getZeroAgent(foundConnection.id), - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - const thread = yield* Effect.tryPromise({ - try: async () => { - console.log('[THREAD_WORKFLOW] Getting thread:', threadId); - const thread = await agent.getThread(threadId.toString()); - console.log('[THREAD_WORKFLOW] Found thread with messages:', thread.messages.length); - return thread; - }, - catch: (error) => ({ _tag: 'GmailApiError' as const, error }), - }); - - if (!thread.messages || thread.messages.length === 0) { - yield* Console.log('[THREAD_WORKFLOW] Thread has no messages, skipping processing'); - return 'Thread has no messages'; - } - - // Initialize workflow engine with default workflows - const workflowEngine = createDefaultWorkflows(); - - // Create workflow context - const workflowContext = { - connectionId: connectionId.toString(), - threadId: threadId.toString(), - thread, - foundConnection, - agent, - env, - }; - - // Execute configured workflows using the workflow engine - const workflowResults = yield* Effect.tryPromise({ - try: async () => { - const allResults = new Map(); - const allErrors = new Map(); - - // Execute all workflows registered in the engine - const workflowNames = workflowEngine.getWorkflowNames(); - - for (const workflowName of workflowNames) { - console.log(`[THREAD_WORKFLOW] Executing workflow: ${workflowName}`); - - try { - const { results, errors } = await workflowEngine.executeWorkflow( - workflowName, - workflowContext, - ); - - // Merge results and errors using efficient Map operations - results.forEach((value, key) => allResults.set(key, value)); - errors.forEach((value, key) => allErrors.set(key, value)); - - console.log(`[THREAD_WORKFLOW] Completed workflow: ${workflowName}`); - } catch (error) { - console.error(`[THREAD_WORKFLOW] Failed to execute workflow ${workflowName}:`, error); - const errorObj = error instanceof Error ? error : new Error(String(error)); - allErrors.set(workflowName, errorObj); - } - } - - return { results: allResults, errors: allErrors }; - }, - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }); - - // Log workflow results - const successfulSteps = Array.from(workflowResults.results.keys()); - const failedSteps = Array.from(workflowResults.errors.keys()); - - if (successfulSteps.length > 0) { - yield* Console.log('[THREAD_WORKFLOW] Successfully executed steps:', successfulSteps); - } - - if (failedSteps.length > 0) { - yield* Console.log('[THREAD_WORKFLOW] Failed steps:', failedSteps); - // Log errors efficiently using forEach to avoid nested iteration - workflowResults.errors.forEach((error, stepId) => { - console.log(`[THREAD_WORKFLOW] Error in step ${stepId}:`, error.message); - }); - } - - // Clean up thread processing flag - yield* Effect.tryPromise({ - try: () => { - console.log('[THREAD_WORKFLOW] Clearing processing flag for thread:', threadId); - return env.gmail_processing_threads.delete(threadId.toString()); - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }).pipe(Effect.orElse(() => Effect.succeed(null))); - - yield* Effect.tryPromise({ - try: async () => conn.end(), - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }); - - yield* Console.log('[THREAD_WORKFLOW] Thread processing complete'); - return 'Thread workflow completed successfully'; - } else { - yield* Console.log('[THREAD_WORKFLOW] Unsupported provider:', providerId); - return yield* Effect.fail({ - _tag: 'UnsupportedProvider' as const, - providerId, - }); - } - }).pipe( - Effect.tapError((error) => Console.log('[THREAD_WORKFLOW] Error in workflow:', error)), - Effect.catchAll((error) => { - // Clean up thread processing flag on error - return Effect.tryPromise({ - try: () => { - console.log( - '[THREAD_WORKFLOW] Clearing processing flag for thread after error:', - params.threadId, - ); - return env.gmail_processing_threads.delete(params.threadId.toString()); - }, - catch: () => ({ - _tag: 'DatabaseError' as const, - error: 'Failed to cleanup thread processing flag', - }), - }).pipe( - Effect.orElse(() => Effect.succeed(null)), - Effect.flatMap(() => Effect.fail(error)), - ); - }), - Effect.provide(loggerLayer), - ); export const getPrompt = async (promptName: string, fallback: string) => { try { diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index f6926f1866..fcfb281c70 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -11,9 +11,47 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { runMainWorkflow, runZeroWorkflow, runThreadWorkflow } from './pipelines.effect'; +import { createDefaultWorkflows } from './thread-workflow-utils/workflow-engine'; +import { getServiceAccount } from './lib/factories/google-subscription.factory'; +import { DurableObject, env } from 'cloudflare:workers'; +import { getZeroAgent } from './lib/server-utils'; +import { type gmail_v1 } from '@googleapis/gmail'; +import { Effect, Console, Logger } from 'effect'; +import { connection } from './db/schema'; +import { EProviders } from './types'; import { EPrompts } from './types'; -import { Effect } from 'effect'; +import { eq } from 'drizzle-orm'; +import { createDb } from './db'; + +// Configure pretty logger to stderr +export const loggerLayer = Logger.add(Logger.prettyLogger({ stderr: true })); + +const isValidUUID = (str: string): boolean => { + const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return regex.test(str); +}; + +const validateArguments = ( + params: MainWorkflowParams, + serviceAccount: { project_id: string }, +): Effect.Effect => + Effect.gen(function* () { + yield* Console.log('[MAIN_WORKFLOW] Validating arguments'); + const regex = new RegExp( + `projects/${serviceAccount.project_id}/subscriptions/notifications__([a-z0-9-]+)`, + ); + const match = params.subscriptionName.toString().match(regex); + if (!match) { + yield* Console.log('[MAIN_WORKFLOW] Invalid subscription name:', params.subscriptionName); + return yield* Effect.fail({ + _tag: 'InvalidSubscriptionName' as const, + subscriptionName: params.subscriptionName, + }); + } + const [, connectionId] = match; + yield* Console.log('[MAIN_WORKFLOW] Extracted connectionId:', connectionId); + return connectionId; + }); // Helper function for generating prompt names export const getPromptName = (connectionId: string, prompt: EPrompts) => { @@ -49,18 +87,597 @@ export type WorkflowParams = | { workflowType: 'thread'; params: ThreadWorkflowParams } | { workflowType: 'zero'; params: ZeroWorkflowParams }; -export const runWorkflow = ( - workflowType: EWorkflowType, - params: MainWorkflowParams | ThreadWorkflowParams | ZeroWorkflowParams, -): Effect.Effect => { - switch (workflowType) { - case EWorkflowType.MAIN: - return runMainWorkflow(params as MainWorkflowParams); - case EWorkflowType.ZERO: - return runZeroWorkflow(params as ZeroWorkflowParams); - case EWorkflowType.THREAD: - return runThreadWorkflow(params as ThreadWorkflowParams); - default: - return Effect.fail({ _tag: 'UnsupportedWorkflow', workflowType }); +export type MainWorkflowError = + | { _tag: 'MissingEnvironmentVariable'; variable: string } + | { _tag: 'InvalidSubscriptionName'; subscriptionName: string } + | { _tag: 'InvalidConnectionId'; connectionId: string } + | { _tag: 'UnsupportedProvider'; providerId: string } + | { _tag: 'WorkflowCreationFailed'; error: unknown }; + +export type ZeroWorkflowError = + | { _tag: 'HistoryAlreadyProcessing'; connectionId: string; historyId: string } + | { _tag: 'ConnectionNotFound'; connectionId: string } + | { _tag: 'ConnectionNotAuthorized'; connectionId: string } + | { _tag: 'HistoryNotFound'; historyId: string; connectionId: string } + | { _tag: 'UnsupportedProvider'; providerId: string } + | { _tag: 'DatabaseError'; error: unknown } + | { _tag: 'GmailApiError'; error: unknown } + | { _tag: 'WorkflowCreationFailed'; error: unknown } + | { _tag: 'LabelModificationFailed'; error: unknown; threadId: string }; + +export type ThreadWorkflowError = + | { _tag: 'ConnectionNotFound'; connectionId: string } + | { _tag: 'ConnectionNotAuthorized'; connectionId: string } + | { _tag: 'ThreadNotFound'; threadId: string } + | { _tag: 'UnsupportedProvider'; providerId: string } + | { _tag: 'DatabaseError'; error: unknown } + | { _tag: 'GmailApiError'; error: unknown } + | { _tag: 'VectorizationError'; error: unknown } + | { _tag: 'WorkflowCreationFailed'; error: unknown }; + +export type UnsupportedWorkflowError = { _tag: 'UnsupportedWorkflow'; workflowType: never }; + +export type WorkflowError = + | MainWorkflowError + | ZeroWorkflowError + | ThreadWorkflowError + | UnsupportedWorkflowError; + +export class WorkflowRunner extends DurableObject { + constructor(state: DurableObjectState, env: Env) { + super(state, env); } -}; + + /** + * This function runs the main workflow. The main workflow is responsible for processing incoming messages from a Pub/Sub subscription and passing them to the appropriate pipeline. + * It validates the subscription name and extracts the connection ID. + * @param params + * @returns + */ + public runMainWorkflow(params: MainWorkflowParams) { + return Effect.gen(this, function* () { + yield* Console.log('[MAIN_WORKFLOW] Starting workflow with payload:', params); + + const { providerId, historyId } = params; + + const serviceAccount = getServiceAccount(); + + const connectionId = yield* validateArguments(params, serviceAccount); + + if (!isValidUUID(connectionId)) { + yield* Console.log('[MAIN_WORKFLOW] Invalid connection id format:', connectionId); + return yield* Effect.fail({ + _tag: 'InvalidConnectionId' as const, + connectionId, + }); + } + + const previousHistoryId = yield* Effect.tryPromise({ + try: () => env.gmail_history_id.get(connectionId), + catch: () => ({ + _tag: 'WorkflowCreationFailed' as const, + error: 'Failed to get history ID', + }), + }).pipe(Effect.orElse(() => Effect.succeed(null))); + + if (providerId === EProviders.google) { + yield* Console.log('[MAIN_WORKFLOW] Processing Google provider workflow'); + yield* Console.log('[MAIN_WORKFLOW] Previous history ID:', previousHistoryId); + + const zeroWorkflowParams = { + connectionId, + historyId: previousHistoryId || historyId, + nextHistoryId: historyId, + }; + + const result = yield* Effect.tryPromise({ + try: () => this.runZeroWorkflow(zeroWorkflowParams), + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), + }); + + yield* Console.log('[MAIN_WORKFLOW] Zero workflow result:', result); + } else { + yield* Console.log('[MAIN_WORKFLOW] Unsupported provider:', providerId); + return yield* Effect.fail({ + _tag: 'UnsupportedProvider' as const, + providerId, + }); + } + + yield* Console.log('[MAIN_WORKFLOW] Workflow completed successfully'); + return 'Workflow completed successfully'; + }).pipe( + Effect.tapError((error) => Console.log('[MAIN_WORKFLOW] Error in workflow:', error)), + Effect.provide(loggerLayer), + Effect.runPromise, + ); + } + + private runZeroWorkflow(params: ZeroWorkflowParams) { + return Effect.gen(this, function* () { + yield* Console.log('[ZERO_WORKFLOW] Starting workflow with payload:', params); + const { connectionId, historyId, nextHistoryId } = params; + + const historyProcessingKey = `history_${connectionId}__${historyId}`; + + // Atomic lock acquisition to prevent race conditions + const lockAcquired = yield* Effect.tryPromise({ + try: async () => { + const response = await env.gmail_processing_threads.put(historyProcessingKey, 'true', { + expirationTtl: 3600, + }); + return response !== null; // null means key already existed + }, + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), + }); + + if (!lockAcquired) { + yield* Console.log('[ZERO_WORKFLOW] History already being processed:', { + connectionId, + historyId, + }); + return yield* Effect.fail({ + _tag: 'HistoryAlreadyProcessing' as const, + connectionId, + historyId, + }); + } + + yield* Console.log( + '[ZERO_WORKFLOW] Acquired processing lock for history:', + historyProcessingKey, + ); + + const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + + const foundConnection = yield* Effect.tryPromise({ + try: async () => { + console.log('[ZERO_WORKFLOW] Finding connection:', connectionId); + const [foundConnection] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId.toString())); + await conn.end(); + if (!foundConnection) { + throw new Error(`Connection not found ${connectionId}`); + } + if (!foundConnection.accessToken || !foundConnection.refreshToken) { + throw new Error(`Connection is not authorized ${connectionId}`); + } + console.log('[ZERO_WORKFLOW] Found connection:', foundConnection.id); + return foundConnection; + }, + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + + yield* Effect.tryPromise({ + try: async () => conn.end(), + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + + const agent = yield* Effect.tryPromise({ + try: async () => await getZeroAgent(foundConnection.id), + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + + if (foundConnection.providerId === EProviders.google) { + yield* Console.log('[ZERO_WORKFLOW] Processing Google provider workflow'); + + const history = yield* Effect.tryPromise({ + try: async () => { + console.log('[ZERO_WORKFLOW] Getting Gmail history with ID:', historyId); + const { history } = (await agent.listHistory(historyId.toString())) as { + history: gmail_v1.Schema$History[]; + }; + console.log('[ZERO_WORKFLOW] Found history entries:', history); + return history; + }, + catch: (error) => ({ _tag: 'GmailApiError' as const, error }), + }); + + yield* Effect.tryPromise({ + try: () => { + console.log('[ZERO_WORKFLOW] Updating next history ID:', nextHistoryId); + return env.gmail_history_id.put(connectionId.toString(), nextHistoryId.toString()); + }, + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), + }); + + if (!history.length) { + yield* Console.log('[ZERO_WORKFLOW] No history found, skipping'); + return 'No history found'; + } + + // Extract thread IDs from history and track label changes + const threadsAdded = new Set(); + const threadLabelChanges = new Map< + string, + { addLabels: Set; removeLabels: Set } + >(); + + // Optimal single-pass functional processing + const processLabelChange = ( + labelChange: { message?: gmail_v1.Schema$Message; labelIds?: string[] | null }, + isAddition: boolean, + ) => { + const threadId = labelChange.message?.threadId; + if (!threadId || !labelChange.labelIds?.length) return; + + let changes = threadLabelChanges.get(threadId); + if (!changes) { + changes = { addLabels: new Set(), removeLabels: new Set() }; + threadLabelChanges.set(threadId, changes); + } + + const targetSet = isAddition ? changes.addLabels : changes.removeLabels; + labelChange.labelIds.forEach((labelId) => targetSet.add(labelId)); + }; + + history.forEach((historyItem) => { + // Extract thread IDs from messages + historyItem.messagesAdded?.forEach((msg) => { + if (msg.message?.threadId) { + threadsAdded.add(msg.message.threadId); + } + }); + + // Process label changes using shared helper + historyItem.labelsAdded?.forEach((labelAdded) => processLabelChange(labelAdded, true)); + historyItem.labelsRemoved?.forEach((labelRemoved) => + processLabelChange(labelRemoved, false), + ); + }); + + yield* Console.log( + '[ZERO_WORKFLOW] Found unique thread IDs:', + Array.from(threadLabelChanges.keys()), + Array.from(threadsAdded), + ); + + if (threadsAdded.size > 0) { + const threadWorkflowParams = Array.from(threadsAdded); + + // Sync threads with proper error handling - use allSuccesses to collect successful syncs + const syncResults = yield* Effect.allSuccesses( + threadWorkflowParams.map((threadId) => + Effect.tryPromise({ + try: async () => { + const result = await agent.syncThread({ threadId }); + console.log(`[ZERO_WORKFLOW] Successfully synced thread ${threadId}`); + return { threadId, result }; + }, + catch: (error) => { + console.error(`[ZERO_WORKFLOW] Failed to sync thread ${threadId}:`, error); + // Let this effect fail so allSuccesses will exclude it + throw new Error( + `Failed to sync thread ${threadId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + }), + ), + { concurrency: 6 }, // Limit concurrency to avoid rate limits + ); + + const syncedCount = syncResults.length; + const failedCount = threadWorkflowParams.length - syncedCount; + + if (failedCount > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Warning: ${failedCount}/${threadWorkflowParams.length} thread syncs failed. Successfully synced: ${syncedCount}`, + ); + // Continue with processing - sync failures shouldn't stop the entire workflow + // The thread processing will continue with whatever data is available + } else { + yield* Console.log(`[ZERO_WORKFLOW] Successfully synced all ${syncedCount} threads`); + } + + yield* Console.log('[ZERO_WORKFLOW] Synced threads:', syncResults); + + // Run thread workflow for each successfully synced thread + if (syncedCount > 0) { + yield* Effect.tryPromise({ + try: () => agent.reloadFolder('inbox'), + catch: (error) => ({ _tag: 'GmailApiError' as const, error }), + }).pipe( + Effect.tap(() => Console.log('[ZERO_WORKFLOW] Successfully reloaded inbox folder')), + Effect.orElse(() => + Effect.gen(function* () { + yield* Console.log('[ZERO_WORKFLOW] Failed to reload inbox folder'); + return undefined; + }), + ), + ); + + yield* Console.log( + `[ZERO_WORKFLOW] Running thread workflows for ${syncedCount} synced threads`, + ); + + const threadWorkflowResults = yield* Effect.allSuccesses( + syncResults.map(({ threadId }) => + this.runThreadWorkflow({ + connectionId, + threadId, + providerId: foundConnection.providerId, + }).pipe( + Effect.tap(() => + Console.log(`[ZERO_WORKFLOW] Successfully ran thread workflow for ${threadId}`), + ), + Effect.tapError((error) => + Console.log( + `[ZERO_WORKFLOW] Failed to run thread workflow for ${threadId}:`, + error, + ), + ), + ), + ), + { concurrency: 6 }, // Limit concurrency to avoid overwhelming the system + ); + + const threadWorkflowSuccessCount = threadWorkflowResults.length; + const threadWorkflowFailedCount = syncedCount - threadWorkflowSuccessCount; + + if (threadWorkflowFailedCount > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Warning: ${threadWorkflowFailedCount}/${syncedCount} thread workflows failed. Successfully processed: ${threadWorkflowSuccessCount}`, + ); + } else { + yield* Console.log( + `[ZERO_WORKFLOW] Successfully ran all ${threadWorkflowSuccessCount} thread workflows`, + ); + } + } + } + + // Process label changes for threads + if (threadLabelChanges.size > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Processing label changes for ${threadLabelChanges.size} threads`, + ); + + // Process each thread's label changes + for (const [threadId, changes] of threadLabelChanges) { + const addLabels = Array.from(changes.addLabels); + const removeLabels = Array.from(changes.removeLabels); + + // Only call if there are actual changes to make + if (addLabels.length > 0 || removeLabels.length > 0) { + yield* Console.log( + `[ZERO_WORKFLOW] Modifying labels for thread ${threadId}: +${addLabels.length} -${removeLabels.length}`, + ); + yield* Effect.tryPromise({ + try: () => agent.modifyThreadLabelsInDB(threadId, addLabels, removeLabels), + catch: (error) => ({ _tag: 'LabelModificationFailed' as const, error, threadId }), + }).pipe( + Effect.orElse(() => + Effect.gen(function* () { + yield* Console.log( + `[ZERO_WORKFLOW] Failed to modify labels for thread ${threadId}`, + ); + return undefined; + }), + ), + ); + } + } + + yield* Console.log('[ZERO_WORKFLOW] Completed label modifications'); + } else { + yield* Console.log('[ZERO_WORKFLOW] No threads with label changes to process'); + } + + // Clean up processing flag + yield* Effect.tryPromise({ + try: () => { + console.log( + '[ZERO_WORKFLOW] Clearing processing flag for history:', + historyProcessingKey, + ); + return env.gmail_processing_threads.delete(historyProcessingKey); + }, + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), + }).pipe(Effect.orElse(() => Effect.succeed(null))); + + yield* Console.log('[ZERO_WORKFLOW] Processing complete'); + return 'Zero workflow completed successfully'; + } else { + yield* Console.log('[ZERO_WORKFLOW] Unsupported provider:', foundConnection.providerId); + return yield* Effect.fail({ + _tag: 'UnsupportedProvider' as const, + providerId: foundConnection.providerId, + }); + } + }).pipe( + Effect.tapError((error) => Console.log('[ZERO_WORKFLOW] Error in workflow:', error)), + Effect.catchAll((error) => { + // Clean up processing flag on error + return Effect.tryPromise({ + try: () => { + console.log( + '[ZERO_WORKFLOW] Clearing processing flag for history after error:', + `history_${params.connectionId}__${params.historyId}`, + ); + return env.gmail_processing_threads.delete( + `history_${params.connectionId}__${params.historyId}`, + ); + }, + catch: () => ({ + _tag: 'WorkflowCreationFailed' as const, + error: 'Failed to cleanup processing flag', + }), + }).pipe( + Effect.orElse(() => Effect.succeed(null)), + Effect.flatMap(() => Effect.fail(error)), + ); + }), + Effect.provide(loggerLayer), + Effect.runPromise, + ); + } + + private runThreadWorkflow(params: ThreadWorkflowParams) { + return Effect.gen(this, function* () { + yield* Console.log('[THREAD_WORKFLOW] Starting workflow with payload:', params); + const { connectionId, threadId, providerId } = params; + + if (providerId === EProviders.google) { + yield* Console.log('[THREAD_WORKFLOW] Processing Google provider workflow'); + const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + + const foundConnection = yield* Effect.tryPromise({ + try: async () => { + console.log('[THREAD_WORKFLOW] Finding connection:', connectionId); + const [foundConnection] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId.toString())); + if (!foundConnection) { + throw new Error(`Connection not found ${connectionId}`); + } + if (!foundConnection.accessToken || !foundConnection.refreshToken) { + throw new Error(`Connection is not authorized ${connectionId}`); + } + console.log('[THREAD_WORKFLOW] Found connection:', foundConnection.id); + return foundConnection; + }, + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + + yield* Effect.tryPromise({ + try: async () => conn.end(), + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + + const agent = yield* Effect.tryPromise({ + try: async () => await getZeroAgent(foundConnection.id), + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }); + + const thread = yield* Effect.tryPromise({ + try: async () => { + console.log('[THREAD_WORKFLOW] Getting thread:', threadId); + const thread = await agent.getThread(threadId.toString()); + console.log('[THREAD_WORKFLOW] Found thread with messages:', thread.messages.length); + return thread; + }, + catch: (error) => ({ _tag: 'GmailApiError' as const, error }), + }); + + if (!thread.messages || thread.messages.length === 0) { + yield* Console.log('[THREAD_WORKFLOW] Thread has no messages, skipping processing'); + return 'Thread has no messages'; + } + + // Initialize workflow engine with default workflows + const workflowEngine = createDefaultWorkflows(); + + // Create workflow context + const workflowContext = { + connectionId: connectionId.toString(), + threadId: threadId.toString(), + thread, + foundConnection, + agent, + env, + results: new Map(), + }; + + // Execute configured workflows using the workflow engine + const workflowResults = yield* Effect.tryPromise({ + try: async () => { + const allResults = new Map(); + const allErrors = new Map(); + + // Execute all workflows registered in the engine + const workflowNames = workflowEngine.getWorkflowNames(); + + for (const workflowName of workflowNames) { + console.log(`[THREAD_WORKFLOW] Executing workflow: ${workflowName}`); + + try { + const { results, errors } = await workflowEngine.executeWorkflow( + workflowName, + workflowContext, + ); + + // Merge results and errors using efficient Map operations + results.forEach((value, key) => allResults.set(key, value)); + errors.forEach((value, key) => allErrors.set(key, value)); + + console.log(`[THREAD_WORKFLOW] Completed workflow: ${workflowName}`); + } catch (error) { + console.error( + `[THREAD_WORKFLOW] Failed to execute workflow ${workflowName}:`, + error, + ); + const errorObj = error instanceof Error ? error : new Error(String(error)); + allErrors.set(workflowName, errorObj); + } + } + + return { results: allResults, errors: allErrors }; + }, + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), + }); + + // Clear workflow context after execution + workflowEngine.clearContext(workflowContext); + + // Log workflow results + const successfulSteps = Array.from(workflowResults.results.keys()); + const failedSteps = Array.from(workflowResults.errors.keys()); + + if (successfulSteps.length > 0) { + yield* Console.log('[THREAD_WORKFLOW] Successfully executed steps:', successfulSteps); + } + + if (failedSteps.length > 0) { + yield* Console.log('[THREAD_WORKFLOW] Failed steps:', failedSteps); + // Log errors efficiently using forEach to avoid nested iteration + workflowResults.errors.forEach((error, stepId) => { + console.log(`[THREAD_WORKFLOW] Error in step ${stepId}:`, error.message); + }); + } + + // Clean up thread processing flag + yield* Effect.tryPromise({ + try: () => { + console.log('[THREAD_WORKFLOW] Clearing processing flag for thread:', threadId); + return env.gmail_processing_threads.delete(threadId.toString()); + }, + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }).pipe(Effect.orElse(() => Effect.succeed(null))); + + yield* Console.log('[THREAD_WORKFLOW] Thread processing complete'); + return 'Thread workflow completed successfully'; + } else { + yield* Console.log('[THREAD_WORKFLOW] Unsupported provider:', providerId); + return yield* Effect.fail({ + _tag: 'UnsupportedProvider' as const, + providerId, + }); + } + }).pipe( + Effect.tapError((error) => Console.log('[THREAD_WORKFLOW] Error in workflow:', error)), + Effect.catchAll((error) => { + // Clean up thread processing flag on error + return Effect.tryPromise({ + try: () => { + console.log( + '[THREAD_WORKFLOW] Clearing processing flag for thread after error:', + params.threadId, + ); + return env.gmail_processing_threads.delete(params.threadId.toString()); + }, + catch: () => ({ + _tag: 'DatabaseError' as const, + error: 'Failed to cleanup thread processing flag', + }), + }).pipe( + Effect.orElse(() => Effect.succeed(null)), + Effect.flatMap(() => Effect.fail(error)), + ); + }), + Effect.provide(loggerLayer), + ); + } +} diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts index 345b8c2c60..ef570e5e30 100644 --- a/apps/server/src/thread-workflow-utils/workflow-engine.ts +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -82,6 +82,13 @@ export class WorkflowEngine { return { results, errors }; } + + clearContext(context: WorkflowContext): void { + if (context.results) { + context.results.clear(); + } + console.log('[WORKFLOW_ENGINE] Context cleared'); + } } export const createDefaultWorkflows = (): WorkflowEngine => { @@ -91,12 +98,23 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'auto-draft-generation', description: 'Automatically generates drafts for threads that require responses', steps: [ + { + id: 'check-workflow-execution', + name: 'Check Workflow Execution', + description: 'Checks if this workflow has already been executed for this thread', + enabled: true, + action: workflowFunctions.checkWorkflowExecution, + }, { id: 'check-draft-eligibility', name: 'Check Draft Eligibility', description: 'Determines if a draft should be generated for this thread', enabled: true, condition: async (context) => { + const executionCheck = context.results?.get('check-workflow-execution'); + if (executionCheck?.alreadyExecuted) { + return false; + } return shouldGenerateDraft(context.thread, context.foundConnection); }, action: async (context) => { @@ -137,6 +155,14 @@ export const createDefaultWorkflows = (): WorkflowEngine => { action: workflowFunctions.createDraft, errorHandling: 'continue', }, + { + id: 'cleanup-workflow-execution', + name: 'Cleanup Workflow Execution', + description: 'Removes workflow execution tracking', + enabled: true, + action: workflowFunctions.cleanupWorkflowExecution, + errorHandling: 'continue', + }, ], }; @@ -144,11 +170,22 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'message-vectorization', description: 'Vectorizes thread messages for search and analysis', steps: [ + { + id: 'check-workflow-execution', + name: 'Check Workflow Execution', + description: 'Checks if this workflow has already been executed for this thread', + enabled: true, + action: workflowFunctions.checkWorkflowExecution, + }, { id: 'find-messages-to-vectorize', name: 'Find Messages to Vectorize', description: 'Identifies messages that need vectorization', enabled: true, + condition: async (context) => { + const executionCheck = context.results?.get('check-workflow-execution'); + return !executionCheck?.alreadyExecuted; + }, action: workflowFunctions.findMessagesToVectorize, }, { @@ -166,6 +203,14 @@ export const createDefaultWorkflows = (): WorkflowEngine => { action: workflowFunctions.upsertEmbeddings, errorHandling: 'continue', }, + { + id: 'cleanup-workflow-execution', + name: 'Cleanup Workflow Execution', + description: 'Removes workflow execution tracking', + enabled: true, + action: workflowFunctions.cleanupWorkflowExecution, + errorHandling: 'continue', + }, ], }; @@ -173,11 +218,22 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'thread-summary', description: 'Generates and stores thread summaries', steps: [ + { + id: 'check-workflow-execution', + name: 'Check Workflow Execution', + description: 'Checks if this workflow has already been executed for this thread', + enabled: true, + action: workflowFunctions.checkWorkflowExecution, + }, { id: 'check-existing-summary', name: 'Check Existing Summary', description: 'Checks if a thread summary already exists', enabled: true, + condition: async (context) => { + const executionCheck = context.results?.get('check-workflow-execution'); + return !executionCheck?.alreadyExecuted; + }, action: workflowFunctions.checkExistingSummary, }, { @@ -196,6 +252,14 @@ export const createDefaultWorkflows = (): WorkflowEngine => { action: workflowFunctions.upsertThreadSummary, errorHandling: 'continue', }, + { + id: 'cleanup-workflow-execution', + name: 'Cleanup Workflow Execution', + description: 'Removes workflow execution tracking', + enabled: true, + action: workflowFunctions.cleanupWorkflowExecution, + errorHandling: 'continue', + }, ], }; @@ -203,11 +267,22 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'label-generation', description: 'Generates and applies labels to threads', steps: [ + { + id: 'check-workflow-execution', + name: 'Check Workflow Execution', + description: 'Checks if this workflow has already been executed for this thread', + enabled: true, + action: workflowFunctions.checkWorkflowExecution, + }, { id: 'get-user-labels', name: 'Get User Labels', description: 'Retrieves user-defined labels', enabled: true, + condition: async (context) => { + const executionCheck = context.results?.get('check-workflow-execution'); + return !executionCheck?.alreadyExecuted; + }, action: workflowFunctions.getUserLabels, }, { @@ -226,6 +301,14 @@ export const createDefaultWorkflows = (): WorkflowEngine => { action: workflowFunctions.applyLabels, errorHandling: 'continue', }, + { + id: 'cleanup-workflow-execution', + name: 'Cleanup Workflow Execution', + description: 'Removes workflow execution tracking', + enabled: true, + action: workflowFunctions.cleanupWorkflowExecution, + errorHandling: 'continue', + }, ], }; diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts index 40f38a0f16..d70c6ec223 100644 --- a/apps/server/src/thread-workflow-utils/workflow-functions.ts +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -21,6 +21,23 @@ export const workflowFunctions: Record = { return shouldGenerateDraft(context.thread, context.foundConnection); }, + checkWorkflowExecution: async (context) => { + const workflowKey = `workflow_${context.threadId}`; + const lastExecution = await env.gmail_processing_threads.get(workflowKey); + + if (lastExecution) { + console.log('[WORKFLOW_FUNCTIONS] Workflow already executed for thread:', context.threadId); + return { alreadyExecuted: true }; + } + + await env.gmail_processing_threads.put(workflowKey, Date.now().toString(), { + expirationTtl: 3600, + }); + + console.log('[WORKFLOW_FUNCTIONS] Marked workflow as executed for thread:', context.threadId); + return { alreadyExecuted: false }; + }, + analyzeEmailIntent: async (context) => { if (!context.thread.messages || context.thread.messages.length === 0) { throw new Error('Cannot analyze email intent: No messages in thread'); @@ -125,8 +142,33 @@ export const workflowFunctions: Record = { const messageIds = context.thread.messages.map((message) => message.id); console.log('[WORKFLOW_FUNCTIONS] Found message IDs:', messageIds); - const existingMessages = await env.VECTORIZE_MESSAGE.getByIds(messageIds); - console.log('[WORKFLOW_FUNCTIONS] Found existing messages:', existingMessages.length); + const batchSize = 20; + const batches = []; + for (let i = 0; i < messageIds.length; i += batchSize) { + batches.push(messageIds.slice(i, i + batchSize)); + } + + const getExistingMessagesBatch = (batch: string[]): Effect.Effect => + Effect.tryPromise(async () => { + console.log('[WORKFLOW_FUNCTIONS] Fetching batch of', batch.length, 'message IDs'); + return await env.VECTORIZE_MESSAGE.getByIds(batch); + }).pipe( + Effect.catchAll((error) => { + console.log('[WORKFLOW_FUNCTIONS] Failed to fetch batch:', error); + return Effect.succeed([]); + }), + ); + + const batchEffects = batches.map(getExistingMessagesBatch); + const program = Effect.all(batchEffects, { concurrency: 3 }).pipe( + Effect.map((results) => { + const allExistingMessages = results.flat(); + console.log('[WORKFLOW_FUNCTIONS] Found existing messages:', allExistingMessages.length); + return allExistingMessages; + }), + ); + + const existingMessages = await Effect.runPromise(program); const existingMessageIds = new Set(existingMessages.map((message: any) => message.id)); const messagesToVectorize = context.thread.messages.filter( @@ -248,6 +290,16 @@ export const workflowFunctions: Record = { return { upserted: vectorizeResult.embeddings.length }; }, + cleanupWorkflowExecution: async (context) => { + const workflowKey = `workflow_${context.threadId}`; + await env.gmail_processing_threads.delete(workflowKey); + console.log( + '[WORKFLOW_FUNCTIONS] Cleaned up workflow execution tracking for thread:', + context.threadId, + ); + return { cleaned: true }; + }, + checkExistingSummary: async (context) => { console.log('[WORKFLOW_FUNCTIONS] Getting existing thread summary for:', context.threadId); const threadSummary = await env.VECTORIZE.getByIds([context.threadId.toString()]); diff --git a/apps/server/wrangler.jsonc b/apps/server/wrangler.jsonc index 9e2d293623..879d87e370 100644 --- a/apps/server/wrangler.jsonc +++ b/apps/server/wrangler.jsonc @@ -47,6 +47,10 @@ "name": "THINKING_MCP", "class_name": "ThinkingMCP", }, + { + "name": "WORKFLOW_RUNNER", + "class_name": "WorkflowRunner", + }, ], }, "queues": { @@ -94,6 +98,10 @@ "tag": "v6", "new_sqlite_classes": ["ThinkingMCP"], }, + { + "tag": "v7", + "new_sqlite_classes": ["WorkflowRunner"], + }, ], "observability": { @@ -199,6 +207,10 @@ "name": "THINKING_MCP", "class_name": "ThinkingMCP", }, + { + "name": "WORKFLOW_RUNNER", + "class_name": "WorkflowRunner", + }, ], }, "r2_buckets": [ @@ -256,6 +268,10 @@ "tag": "v7", "new_sqlite_classes": ["ThinkingMCP"], }, + { + "tag": "v8", + "new_sqlite_classes": ["WorkflowRunner"], + }, ], "observability": { "enabled": true, @@ -364,6 +380,10 @@ "name": "THINKING_MCP", "class_name": "ThinkingMCP", }, + { + "name": "WORKFLOW_RUNNER", + "class_name": "WorkflowRunner", + }, ], }, "queues": { @@ -415,6 +435,10 @@ "tag": "v7", "new_sqlite_classes": ["ThinkingMCP"], }, + { + "tag": "v8", + "new_sqlite_classes": ["WorkflowRunner"], + }, ], "vars": { "NODE_ENV": "production", From 4c86d591ffcdbed95bc284d549f329a0eef6feb1 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:34:46 -0700 Subject: [PATCH 13/48] Fix WorkflowRunner to use this.env instead of global env (#1857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Fix WorkflowRunner to use instance environment variables ## Description This PR fixes an issue in the `WorkflowRunner` class where global `env` was being used instead of the instance's `this.env`. All references to the global `env` variable have been replaced with `this.env` to ensure proper access to environment variables within the Durable Object context. ## Type of Change - [x] 🐛 Bug fix (non-breaking change which fixes an issue) ## Areas Affected - [x] Email Integration (Gmail, IMAP, etc.) ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] All tests pass locally ## Additional Notes This change ensures that the WorkflowRunner correctly accesses environment variables through the instance's context rather than relying on the global environment, which could lead to undefined behavior in a Cloudflare Workers environment. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- apps/server/src/pipelines.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index fcfb281c70..097005a1e8 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -13,7 +13,7 @@ */ import { createDefaultWorkflows } from './thread-workflow-utils/workflow-engine'; import { getServiceAccount } from './lib/factories/google-subscription.factory'; -import { DurableObject, env } from 'cloudflare:workers'; +import { DurableObject } from 'cloudflare:workers'; import { getZeroAgent } from './lib/server-utils'; import { type gmail_v1 } from '@googleapis/gmail'; import { Effect, Console, Logger } from 'effect'; @@ -153,7 +153,7 @@ export class WorkflowRunner extends DurableObject { } const previousHistoryId = yield* Effect.tryPromise({ - try: () => env.gmail_history_id.get(connectionId), + try: () => this.env.gmail_history_id.get(connectionId), catch: () => ({ _tag: 'WorkflowCreationFailed' as const, error: 'Failed to get history ID', @@ -203,9 +203,13 @@ export class WorkflowRunner extends DurableObject { // Atomic lock acquisition to prevent race conditions const lockAcquired = yield* Effect.tryPromise({ try: async () => { - const response = await env.gmail_processing_threads.put(historyProcessingKey, 'true', { - expirationTtl: 3600, - }); + const response = await this.env.gmail_processing_threads.put( + historyProcessingKey, + 'true', + { + expirationTtl: 3600, + }, + ); return response !== null; // null means key already existed }, catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), @@ -228,7 +232,7 @@ export class WorkflowRunner extends DurableObject { historyProcessingKey, ); - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); const foundConnection = yield* Effect.tryPromise({ try: async () => { @@ -278,7 +282,7 @@ export class WorkflowRunner extends DurableObject { yield* Effect.tryPromise({ try: () => { console.log('[ZERO_WORKFLOW] Updating next history ID:', nextHistoryId); - return env.gmail_history_id.put(connectionId.toString(), nextHistoryId.toString()); + return this.env.gmail_history_id.put(connectionId.toString(), nextHistoryId.toString()); }, catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), }); @@ -472,7 +476,7 @@ export class WorkflowRunner extends DurableObject { '[ZERO_WORKFLOW] Clearing processing flag for history:', historyProcessingKey, ); - return env.gmail_processing_threads.delete(historyProcessingKey); + return this.env.gmail_processing_threads.delete(historyProcessingKey); }, catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), }).pipe(Effect.orElse(() => Effect.succeed(null))); @@ -496,7 +500,7 @@ export class WorkflowRunner extends DurableObject { '[ZERO_WORKFLOW] Clearing processing flag for history after error:', `history_${params.connectionId}__${params.historyId}`, ); - return env.gmail_processing_threads.delete( + return this.env.gmail_processing_threads.delete( `history_${params.connectionId}__${params.historyId}`, ); }, @@ -521,7 +525,7 @@ export class WorkflowRunner extends DurableObject { if (providerId === EProviders.google) { yield* Console.log('[THREAD_WORKFLOW] Processing Google provider workflow'); - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); const foundConnection = yield* Effect.tryPromise({ try: async () => { @@ -577,7 +581,7 @@ export class WorkflowRunner extends DurableObject { thread, foundConnection, agent, - env, + env: this.env, results: new Map(), }; @@ -642,7 +646,7 @@ export class WorkflowRunner extends DurableObject { yield* Effect.tryPromise({ try: () => { console.log('[THREAD_WORKFLOW] Clearing processing flag for thread:', threadId); - return env.gmail_processing_threads.delete(threadId.toString()); + return this.env.gmail_processing_threads.delete(threadId.toString()); }, catch: (error) => ({ _tag: 'DatabaseError' as const, error }), }).pipe(Effect.orElse(() => Effect.succeed(null))); @@ -666,7 +670,7 @@ export class WorkflowRunner extends DurableObject { '[THREAD_WORKFLOW] Clearing processing flag for thread after error:', params.threadId, ); - return env.gmail_processing_threads.delete(params.threadId.toString()); + return this.env.gmail_processing_threads.delete(params.threadId.toString()); }, catch: () => ({ _tag: 'DatabaseError' as const, From e9d32f45edd75836416f314731b21448534c9ed8 Mon Sep 17 00:00:00 2001 From: Ahmet Kilinc Date: Tue, 29 Jul 2025 18:43:27 +0100 Subject: [PATCH 14/48] fix: add draft generation check (#1855) # Prevent duplicate draft generation by checking existing drafts ## Description This PR enhances the `shouldGenerateDraft` function to prevent generating duplicate drafts for the same email thread. The function now: 1. Checks if a draft already exists for the thread by calling the Gmail API 2. Skips draft generation if a draft is already present for that thread 3. Logs the decision for better debugging ## Summary by CodeRabbit * **Bug Fixes** * Improved draft generation logic to prevent creating duplicate drafts for the same thread. * Enhanced workflow reliability by ensuring eligibility checks are properly awaited. * **Chores** * Added detailed logging for draft generation, including thread message counts and sender information. --- .../server/src/thread-workflow-utils/index.ts | 28 +++++++++++++++++-- .../thread-workflow-utils/workflow-engine.ts | 2 +- .../workflow-functions.ts | 7 ++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/server/src/thread-workflow-utils/index.ts b/apps/server/src/thread-workflow-utils/index.ts index 80c5d56a01..75bed52996 100644 --- a/apps/server/src/thread-workflow-utils/index.ts +++ b/apps/server/src/thread-workflow-utils/index.ts @@ -1,12 +1,13 @@ import type { IGetThreadResponse } from '../lib/driver/types'; import { composeEmail } from '../trpc/routes/ai/compose'; +import { getZeroAgent } from '../lib/server-utils'; import { type ParsedMessage } from '../types'; import { connection } from '../db/schema'; -const shouldGenerateDraft = ( +const shouldGenerateDraft = async ( thread: IGetThreadResponse, foundConnection: typeof connection.$inferSelect, -): boolean => { +): Promise => { if (!thread.messages || thread.messages.length === 0) return false; const latestMessage = thread.messages[thread.messages.length - 1]; @@ -37,6 +38,29 @@ const shouldGenerateDraft = ( } } + try { + const agent = await getZeroAgent(foundConnection.id); + + const threadId = thread.messages[0]?.threadId; + if (!threadId) { + console.log('[SHOULD_GENERATE_DRAFT] No thread ID found, skipping draft check'); + return true; + } + + const draftsResponse: any = await agent.listDrafts({ maxResults: 100 }); + + const hasDraftForThread = draftsResponse.threads.some((draft: any) => { + return draft.id === threadId; + }); + + if (hasDraftForThread) { + console.log(`[SHOULD_GENERATE_DRAFT] Draft already exists for thread ${threadId}, skipping`); + return false; + } + } catch (error) { + console.error('[SHOULD_GENERATE_DRAFT] Error checking for existing drafts:', error); + } + return true; }; diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts index ef570e5e30..792fe35650 100644 --- a/apps/server/src/thread-workflow-utils/workflow-engine.ts +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -115,7 +115,7 @@ export const createDefaultWorkflows = (): WorkflowEngine => { if (executionCheck?.alreadyExecuted) { return false; } - return shouldGenerateDraft(context.thread, context.foundConnection); + return await shouldGenerateDraft(context.thread, context.foundConnection); }, action: async (context) => { console.log('[WORKFLOW_ENGINE] Thread eligible for draft generation', { diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts index d70c6ec223..ecaa412a5c 100644 --- a/apps/server/src/thread-workflow-utils/workflow-functions.ts +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -18,7 +18,7 @@ export type WorkflowFunction = (context: WorkflowContext) => Promise; export const workflowFunctions: Record = { shouldGenerateDraft: async (context) => { - return shouldGenerateDraft(context.thread, context.foundConnection); + return await shouldGenerateDraft(context.thread, context.foundConnection); }, checkWorkflowExecution: async (context) => { @@ -80,6 +80,11 @@ export const workflowFunctions: Record = { generateAutomaticDraft: async (context) => { console.log('[WORKFLOW_FUNCTIONS] Generating automatic draft for thread:', context.threadId); + console.log('[WORKFLOW_FUNCTIONS] Thread has', context.thread.messages.length, 'messages'); + console.log( + '[WORKFLOW_FUNCTIONS] Latest message from:', + context.thread.messages[context.thread.messages.length - 1]?.sender?.email, + ); const draftContent = await generateAutomaticDraft( context.connectionId, From 6a976215dae3818926ebba7aadd0116e97f051b4 Mon Sep 17 00:00:00 2001 From: amrit Date: Tue, 29 Jul 2025 23:16:16 +0530 Subject: [PATCH 15/48] feat: add a transition when user switches emails (#1854) based on the featurebase issue: https://linear.app/0email/issue/ZERO-366/beautiful-animation-between-switching-emails --- ## Summary by cubic Added a smooth transition animation when switching between emails, with a new user setting to enable or disable this effect. - **New Features** - Added an "Animations" toggle in settings. - Email view now animates when moving to the next or previous email if enabled. ## Summary by CodeRabbit * **New Features** * Added an option in user settings to enable or disable smooth animations when switching between email threads. * Introduced animated transitions for email thread display, providing a sliding and fading effect when navigating between threads if animations are enabled. * **Bug Fixes** * None. * **Chores** * Updated settings schema and defaults to support the new animation preference. * Added localization entries for the new animations setting. --- .../app/(routes)/settings/general/page.tsx | 21 +++ apps/mail/components/mail/thread-display.tsx | 158 +++++++++++++----- apps/mail/hooks/use-animations.ts | 7 + apps/mail/messages/en.json | 4 +- apps/server/src/lib/schemas.ts | 2 + 5 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 apps/mail/hooks/use-animations.ts diff --git a/apps/mail/app/(routes)/settings/general/page.tsx b/apps/mail/app/(routes)/settings/general/page.tsx index ff39f653f1..db742e6016 100644 --- a/apps/mail/app/(routes)/settings/general/page.tsx +++ b/apps/mail/app/(routes)/settings/general/page.tsx @@ -38,6 +38,7 @@ import { m } from '@/paraglide/messages'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import * as z from 'zod'; +import { useCallback } from 'react'; const TimezoneSelect = memo( ({ field }: { field: ControllerRenderProps, 'timezone'> }) => { @@ -134,6 +135,7 @@ export default function GeneralPage() { customPrompt: '', zeroSignature: true, defaultEmailAlias: '', + animations: false, }, }); @@ -178,6 +180,20 @@ export default function GeneralPage() { } } + const renderAnimationsField = useCallback(({ field }: { field: any }) => ( + +
+ {m['pages.settings.general.animations']()} + + {m['pages.settings.general.animationsDescription']()} + +
+ + + +
+ ), []); + return (
)} /> + diff --git a/apps/mail/components/mail/thread-display.tsx b/apps/mail/components/mail/thread-display.tsx index 651faaec54..56729adac2 100644 --- a/apps/mail/components/mail/thread-display.tsx +++ b/apps/mail/components/mail/thread-display.tsx @@ -53,6 +53,8 @@ import { useQueryState } from 'nuqs'; import { format } from 'date-fns'; import { useAtom } from 'jotai'; import { toast } from 'sonner'; +import { AnimatePresence, motion } from 'motion/react'; +import { useAnimations } from '@/hooks/use-animations'; const formatFileSize = (size: number) => { const sizeInMB = (size / (1024 * 1024)).toFixed(2); @@ -169,6 +171,10 @@ export function ThreadDisplay() { const [, items] = useThreads(); const [isStarred, setIsStarred] = useState(false); const [isImportant, setIsImportant] = useState(false); + + const [navigationDirection, setNavigationDirection] = useState<'previous' | 'next' | null>(null); + + const animationsEnabled = useAnimations(); // Collect all attachments from all messages in the thread const allThreadAttachments = useMemo(() => { @@ -204,6 +210,9 @@ export function ThreadDisplay() { setDraftId(null); setThreadId(prevThread.id); setFocusedIndex(focusedIndex - 1); + if (animationsEnabled) { + setNavigationDirection('previous'); + } } } }, [ @@ -215,6 +224,7 @@ export function ThreadDisplay() { setMode, setActiveReplyId, setDraftId, + animationsEnabled, ]); const handleNext = useCallback(() => { @@ -230,6 +240,9 @@ export function ThreadDisplay() { setDraftId(null); setThreadId(nextThread.id); setFocusedIndex(focusedIndex + 1); + if (animationsEnabled) { + setNavigationDirection('next'); + } } } }, [ @@ -241,6 +254,7 @@ export function ThreadDisplay() { setMode, setActiveReplyId, setDraftId, + animationsEnabled, ]); const handleUnsubscribeProcess = () => { @@ -724,6 +738,10 @@ export function ThreadDisplay() { } }, [mode, activeReplyId]); + const handleAnimationComplete = useCallback(() => { + setNavigationDirection(null); + }, [setNavigationDirection]); + return (
- -
- {(emailData.messages || []).map((message, index) => { - const isLastMessage = index === emailData.messages.length - 1; - const isReplyingToThisMessage = mode && activeReplyId === message.id; - - return ( -
0 && 'border-border border-t', - )} - > - - {/* Inline Reply Compose for non-last messages */} - {isReplyingToThisMessage && !isLastMessage && ( -
- -
- )} -
- ); - })} -
-
+ {animationsEnabled ? ( + + + + + + ) : ( + + )} - {/* Sticky Reply Compose at Bottom - Only for last message */} {mode && activeReplyId && activeReplyId === emailData.messages[emailData.messages.length - 1]?.id && ( @@ -1048,3 +1073,60 @@ export function ThreadDisplay() {
); } + +interface MessageListProps { + messages: ParsedMessage[]; + isFullscreen: boolean; + totalReplies?: number; + allThreadAttachments?: Attachment[]; + mode?: string; + activeReplyId?: string; + isMobile: boolean; +} + +const MessageList = ({ + messages, + isFullscreen, + totalReplies, + allThreadAttachments, + mode, + activeReplyId, + isMobile +}: MessageListProps) => ( + +
+ {(messages || []).map((message, index) => { + const isLastMessage = index === messages.length - 1; + const isReplyingToThisMessage = mode && activeReplyId === message.id; + + return ( +
0 && 'border-border border-t', + )} + > + + {isReplyingToThisMessage && !isLastMessage && ( +
+ +
+ )} +
+ ); + })} +
+
+); diff --git a/apps/mail/hooks/use-animations.ts b/apps/mail/hooks/use-animations.ts new file mode 100644 index 0000000000..3688704c27 --- /dev/null +++ b/apps/mail/hooks/use-animations.ts @@ -0,0 +1,7 @@ +import { useSettings } from './use-settings'; + +export function useAnimations() { + const { data } = useSettings(); + + return data?.settings?.animations ?? false; +} \ No newline at end of file diff --git a/apps/mail/messages/en.json b/apps/mail/messages/en.json index b24281dd60..e68e5c0419 100644 --- a/apps/mail/messages/en.json +++ b/apps/mail/messages/en.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Select default email", "defaultEmailDescription": "This email will be used as the default 'From' address when composing new emails", "autoRead": "Auto Read", - "autoReadDescription": "Automatically mark emails as read when you click on them." + "autoReadDescription": "Automatically mark emails as read when you click on them.", + "animations": "Animations", + "animationsDescription": "Enable smooth animations when switching between emails" }, "connections": { "title": "Email Connections", diff --git a/apps/server/src/lib/schemas.ts b/apps/server/src/lib/schemas.ts index 02e5459d4b..f666cba464 100644 --- a/apps/server/src/lib/schemas.ts +++ b/apps/server/src/lib/schemas.ts @@ -130,6 +130,7 @@ export const userSettingsSchema = z.object({ defaultEmailAlias: z.string().optional(), imageCompression: z.enum(['low', 'medium', 'original']).default('medium'), autoRead: z.boolean().default(true), + animations: z.boolean().default(false), }); export type UserSettings = z.infer; @@ -148,4 +149,5 @@ export const defaultUserSettings: UserSettings = { defaultEmailAlias: '', categories: defaultMailCategories, imageCompression: 'medium', + animations: false, }; From 4639fd9b8dbee63d331b3b3ada6066032cf17d5f Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:08:20 -0700 Subject: [PATCH 16/48] Switch to Llama 4 Scout model and add delay in workflow execution (#1859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Switched the summarization model to Llama 4 Scout and added a 1-second delay before workflow execution to improve reliability. - **Dependencies** - Updated model from Llama 3 to Llama 4 Scout for thread summarization. - **Performance** - Added a 1-second delay before marking workflows as executed. --- .../src/thread-workflow-utils/workflow-functions.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts index ecaa412a5c..9d401b3b55 100644 --- a/apps/server/src/thread-workflow-utils/workflow-functions.ts +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -30,6 +30,8 @@ export const workflowFunctions: Record = { return { alreadyExecuted: true }; } + await new Promise((resolve) => setTimeout(resolve, 1000)); + await env.gmail_processing_threads.put(workflowKey, Date.now().toString(), { expirationTtl: 3600, }); @@ -570,10 +572,10 @@ const summarizeThread = async ( content: prompt, }, ]; - const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + const response = await env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', { messages: promptMessages, }); - const summary = response?.response; + const summary = response.response; return typeof summary === 'string' ? summary : null; } else { const SummarizeThreadPrompt = await getPrompt( @@ -587,10 +589,10 @@ const summarizeThread = async ( content: prompt, }, ]; - const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + const response = await env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', { messages: promptMessages, }); - const summary = response?.response; + const summary = response.response; return typeof summary === 'string' ? summary : null; } } catch (error) { From e6165e830451d83d0546dff845bafb74a2ac100c Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:09:23 -0700 Subject: [PATCH 17/48] Update thread labels workflow to use Llama 4 Scout model (#1860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Updated the thread labels workflow to use the Llama 4 Scout model for improved label generation accuracy. --- apps/server/src/thread-workflow-utils/workflow-functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts index 9d401b3b55..d5fad8e127 100644 --- a/apps/server/src/thread-workflow-utils/workflow-functions.ts +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -441,7 +441,7 @@ export const workflowFunctions: Record = { threadLabels: context.thread.labels, }); - const labelsResponse: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + const labelsResponse = await env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', { messages: [ { role: 'system', content: ThreadLabels(userLabels, context.thread.labels) }, { role: 'user', content: summaryResult.summary }, From def4234d55bea1de50998275f21fa91a4b25c244 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:50:08 -0700 Subject: [PATCH 18/48] Add bulk delete utility for Cloudflare KV keys in workflow processing (#1861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added a utility for bulk deleting Cloudflare KV keys and updated workflow processing to use it for more efficient cleanup. - **Dependencies** - Added the Cloudflare SDK as a new dependency. --- apps/server/package.json | 1 + apps/server/src/lib/bulk-delete.ts | 62 ++++++++++++++ apps/server/src/pipelines.ts | 85 ++++++++++++------- apps/server/src/routes/agent/index.ts | 72 +++++++--------- .../workflow-functions.ts | 5 +- pnpm-lock.yaml | 18 ++++ 6 files changed, 173 insertions(+), 70 deletions(-) create mode 100644 apps/server/src/lib/bulk-delete.ts diff --git a/apps/server/package.json b/apps/server/package.json index 34982dc9b9..7eae5c0d34 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "base64-js": "1.5.1", "better-auth": "catalog:", "cheerio": "1.1.0", + "cloudflare": "4.5.0", "date-fns": "^4.1.0", "dedent": "^1.6.0", "drizzle-orm": "catalog:", diff --git a/apps/server/src/lib/bulk-delete.ts b/apps/server/src/lib/bulk-delete.ts new file mode 100644 index 0000000000..76d429524f --- /dev/null +++ b/apps/server/src/lib/bulk-delete.ts @@ -0,0 +1,62 @@ +import { env } from 'cloudflare:workers'; +import Cloudflare from 'cloudflare'; + +// KV namespace IDs for different environments +const KV_NAMESPACE_IDS = { + local: 'b7db3a98a80f4e16a8b6edc5fa8c7b76', + staging: 'b7db3a98a80f4e16a8b6edc5fa8c7b76', + production: '3348ff0976284269a8d8a5e6e4c04c56', +} as const; + +export type Environment = 'local' | 'staging' | 'production'; + +export interface BulkDeleteResult { + successful: number; + failed: number; +} + +/** + * Bulk delete keys from Cloudflare KV namespace + * @param keys Array of keys to delete + * @param environment Environment to use (defaults to 'local') + * @returns Promise with deletion results + */ +export const bulkDeleteKeys = async ( + keys: string[], + environment: Environment = env.NODE_ENV as Environment, +): Promise => { + if (keys.length === 0) { + return { successful: 0, failed: 0 }; + } + + const namespaceId = KV_NAMESPACE_IDS[environment]; + const accountId = env.CLOUDFLARE_ACCOUNT_ID; + + if (!accountId) { + console.error('[BULK_DELETE] CLOUDFLARE_ACCOUNT_ID environment variable not set'); + return { successful: 0, failed: keys.length }; + } + + try { + const cloudflareClient = new Cloudflare({ + apiToken: env.CLOUDFLARE_API_TOKEN || '', + }); + const response = await cloudflareClient.kv.namespaces.bulkDelete(namespaceId, { + account_id: accountId, + body: keys, + }); + + const successful = response?.successful_key_count || 0; + const failed = keys.length - successful; + + console.log(`[BULK_DELETE] Successfully deleted ${successful}/${keys.length} keys`); + if (failed > 0) { + console.warn(`[BULK_DELETE] Failed to delete ${failed} keys`); + } + + return { successful, failed }; + } catch (error) { + console.error('[BULK_DELETE] Failed to bulk delete keys:', error); + return { successful: 0, failed: keys.length }; + } +}; diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index 097005a1e8..887aeeac67 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -14,6 +14,7 @@ import { createDefaultWorkflows } from './thread-workflow-utils/workflow-engine'; import { getServiceAccount } from './lib/factories/google-subscription.factory'; import { DurableObject } from 'cloudflare:workers'; +import { bulkDeleteKeys } from './lib/bulk-delete'; import { getZeroAgent } from './lib/server-utils'; import { type gmail_v1 } from '@googleapis/gmail'; import { Effect, Console, Logger } from 'effect'; @@ -199,6 +200,7 @@ export class WorkflowRunner extends DurableObject { const { connectionId, historyId, nextHistoryId } = params; const historyProcessingKey = `history_${connectionId}__${historyId}`; + const keysToDelete: string[] = []; // Atomic lock acquisition to prevent race conditions const lockAcquired = yield* Effect.tryPromise({ @@ -289,6 +291,8 @@ export class WorkflowRunner extends DurableObject { if (!history.length) { yield* Console.log('[ZERO_WORKFLOW] No history found, skipping'); + // Add the history processing key to cleanup list + keysToDelete.push(historyProcessingKey); return 'No history found'; } @@ -469,17 +473,23 @@ export class WorkflowRunner extends DurableObject { yield* Console.log('[ZERO_WORKFLOW] No threads with label changes to process'); } - // Clean up processing flag - yield* Effect.tryPromise({ - try: () => { - console.log( - '[ZERO_WORKFLOW] Clearing processing flag for history:', - historyProcessingKey, - ); - return this.env.gmail_processing_threads.delete(historyProcessingKey); - }, - catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), - }).pipe(Effect.orElse(() => Effect.succeed(null))); + // Add history processing key to cleanup list + keysToDelete.push(historyProcessingKey); + + // Bulk delete all collected keys + if (keysToDelete.length > 0) { + yield* Effect.tryPromise({ + try: async () => { + console.log('[ZERO_WORKFLOW] Bulk deleting keys:', keysToDelete); + const result = await bulkDeleteKeys(keysToDelete); + console.log('[ZERO_WORKFLOW] Bulk delete result:', result); + return result; + }, + catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), + }).pipe( + Effect.orElse(() => Effect.succeed({ successful: 0, failed: keysToDelete.length })), + ); + } yield* Console.log('[ZERO_WORKFLOW] Processing complete'); return 'Zero workflow completed successfully'; @@ -493,23 +503,24 @@ export class WorkflowRunner extends DurableObject { }).pipe( Effect.tapError((error) => Console.log('[ZERO_WORKFLOW] Error in workflow:', error)), Effect.catchAll((error) => { - // Clean up processing flag on error + // Clean up processing flag on error using bulk delete return Effect.tryPromise({ - try: () => { + try: async () => { + const errorCleanupKey = `history_${params.connectionId}__${params.historyId}`; console.log( '[ZERO_WORKFLOW] Clearing processing flag for history after error:', - `history_${params.connectionId}__${params.historyId}`, - ); - return this.env.gmail_processing_threads.delete( - `history_${params.connectionId}__${params.historyId}`, + errorCleanupKey, ); + const result = await bulkDeleteKeys([errorCleanupKey]); + console.log('[ZERO_WORKFLOW] Error cleanup result:', result); + return result; }, catch: () => ({ _tag: 'WorkflowCreationFailed' as const, error: 'Failed to cleanup processing flag', }), }).pipe( - Effect.orElse(() => Effect.succeed(null)), + Effect.orElse(() => Effect.succeed({ successful: 0, failed: 1 })), Effect.flatMap(() => Effect.fail(error)), ); }), @@ -522,6 +533,7 @@ export class WorkflowRunner extends DurableObject { return Effect.gen(this, function* () { yield* Console.log('[THREAD_WORKFLOW] Starting workflow with payload:', params); const { connectionId, threadId, providerId } = params; + const keysToDelete: string[] = []; if (providerId === EProviders.google) { yield* Console.log('[THREAD_WORKFLOW] Processing Google provider workflow'); @@ -568,6 +580,8 @@ export class WorkflowRunner extends DurableObject { if (!thread.messages || thread.messages.length === 0) { yield* Console.log('[THREAD_WORKFLOW] Thread has no messages, skipping processing'); + // Add thread processing key to cleanup list + keysToDelete.push(threadId.toString()); return 'Thread has no messages'; } @@ -642,14 +656,23 @@ export class WorkflowRunner extends DurableObject { }); } - // Clean up thread processing flag - yield* Effect.tryPromise({ - try: () => { - console.log('[THREAD_WORKFLOW] Clearing processing flag for thread:', threadId); - return this.env.gmail_processing_threads.delete(threadId.toString()); - }, - catch: (error) => ({ _tag: 'DatabaseError' as const, error }), - }).pipe(Effect.orElse(() => Effect.succeed(null))); + // Add thread processing key to cleanup list + keysToDelete.push(threadId.toString()); + + // Bulk delete all collected keys + if (keysToDelete.length > 0) { + yield* Effect.tryPromise({ + try: async () => { + console.log('[THREAD_WORKFLOW] Bulk deleting keys:', keysToDelete); + const result = await bulkDeleteKeys(keysToDelete); + console.log('[THREAD_WORKFLOW] Bulk delete result:', result); + return result; + }, + catch: (error) => ({ _tag: 'DatabaseError' as const, error }), + }).pipe( + Effect.orElse(() => Effect.succeed({ successful: 0, failed: keysToDelete.length })), + ); + } yield* Console.log('[THREAD_WORKFLOW] Thread processing complete'); return 'Thread workflow completed successfully'; @@ -663,21 +686,23 @@ export class WorkflowRunner extends DurableObject { }).pipe( Effect.tapError((error) => Console.log('[THREAD_WORKFLOW] Error in workflow:', error)), Effect.catchAll((error) => { - // Clean up thread processing flag on error + // Clean up thread processing flag on error using bulk delete return Effect.tryPromise({ - try: () => { + try: async () => { console.log( '[THREAD_WORKFLOW] Clearing processing flag for thread after error:', params.threadId, ); - return this.env.gmail_processing_threads.delete(params.threadId.toString()); + const result = await bulkDeleteKeys([params.threadId.toString()]); + console.log('[THREAD_WORKFLOW] Error cleanup result:', result); + return result; }, catch: () => ({ _tag: 'DatabaseError' as const, error: 'Failed to cleanup thread processing flag', }), }).pipe( - Effect.orElse(() => Effect.succeed(null)), + Effect.orElse(() => Effect.succeed({ successful: 0, failed: 1 })), Effect.flatMap(() => Effect.fail(error)), ); }), diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 5484fdced1..5e61bd59ce 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -313,11 +313,9 @@ export class ZeroDriver extends AIChatAgent { } async getUserTopics(): Promise { - const self = this; - // Create the Effect with proper types - no external requirements needed - const topicGenerationEffect = Effect.gen(function* () { - console.log(`[getUserTopics] Starting topic generation for connection: ${self.name}`); + const topicGenerationEffect = Effect.gen(this, function* () { + console.log(`[getUserTopics] Starting topic generation for connection: ${this.name}`); const result: TopicGenerationResult = { topics: [], @@ -329,7 +327,7 @@ export class ZeroDriver extends AIChatAgent { }; // Check storage first - const stored = yield* Effect.tryPromise(() => self.ctx.storage.get(TOPIC_CACHE_KEY)).pipe( + const stored = yield* Effect.tryPromise(() => this.ctx.storage.get(TOPIC_CACHE_KEY)).pipe( Effect.tap(() => Effect.sync(() => console.log(`[getUserTopics] Checking storage for cached topics`)), ), @@ -388,13 +386,13 @@ export class ZeroDriver extends AIChatAgent { // Generate new topics console.log(`[getUserTopics] Generating new topics`); - const subjects = self.getAllSubjects(); + const subjects = this.getAllSubjects(); result.subjectsAnalyzed = subjects.length; console.log(`[getUserTopics] Found ${subjects.length} subjects for analysis`); let existingLabels: { name: string; id: string }[] = []; - const existingLabelsResult = yield* Effect.tryPromise(() => self.getUserLabels()).pipe( + const existingLabelsResult = yield* Effect.tryPromise(() => this.getUserLabels()).pipe( Effect.tap((labels) => Effect.sync(() => { result.existingLabelsCount = labels.length; @@ -445,7 +443,7 @@ export class ZeroDriver extends AIChatAgent { const topicName = topic.topic.toLowerCase(); if (!existingLabelNames.has(topicName)) { console.log(`[getUserTopics] Creating label for topic: ${topic.topic}`); - await self.createLabel({ + await this.createLabel({ name: topic.topic, }); createdCount++; @@ -466,7 +464,7 @@ export class ZeroDriver extends AIChatAgent { // Store the result yield* Effect.tryPromise(() => - self.ctx.storage.put(TOPIC_CACHE_KEY, { + this.ctx.storage.put(TOPIC_CACHE_KEY, { topics, timestamp: Date.now(), }), @@ -481,9 +479,9 @@ export class ZeroDriver extends AIChatAgent { ); // Broadcast message if agent exists - if (self.agent) { + if (this.agent) { yield* Effect.tryPromise(() => - self.agent!.broadcastChatMessage({ + this.agent!.broadcastChatMessage({ type: OutgoingMessageType.User_Topics, }), ).pipe( @@ -505,7 +503,7 @@ export class ZeroDriver extends AIChatAgent { console.log(`[getUserTopics] No topics generated`); } - console.log(`[getUserTopics] Completed topic generation for connection: ${self.name}`, { + console.log(`[getUserTopics] Completed topic generation for connection: ${this.name}`, { topicsCount: result.topics.length, cacheHit: result.cacheHit, subjectsAnalyzed: result.subjectsAnalyzed, @@ -830,8 +828,6 @@ export class ZeroDriver extends AIChatAgent { } async syncThread({ threadId }: { threadId: string }): Promise { - const self = this; - if (this.name === 'general') { return { success: true, threadId, broadcastSent: false }; } @@ -842,7 +838,7 @@ export class ZeroDriver extends AIChatAgent { } return Effect.runPromise( - Effect.gen(function* () { + Effect.gen(this, function* () { console.log(`[syncThread] Starting sync for thread: ${threadId}`); const result: ThreadSyncResult = { @@ -852,8 +848,8 @@ export class ZeroDriver extends AIChatAgent { }; // Setup driver if needed - if (!self.driver) { - yield* Effect.tryPromise(() => self.setupAuth()).pipe( + if (!this.driver) { + yield* Effect.tryPromise(() => this.setupAuth()).pipe( Effect.tap(() => Effect.sync(() => console.log(`[syncThread] Setup auth completed`))), Effect.catchAll((error) => { console.error(`[syncThread] Failed to setup auth:`, error); @@ -862,17 +858,17 @@ export class ZeroDriver extends AIChatAgent { ); } - if (!self.driver) { + if (!this.driver) { console.error(`[syncThread] No driver available for thread ${threadId}`); result.success = false; result.reason = 'No driver available'; return result; } - self.syncThreadsInProgress.set(threadId, true); + this.syncThreadsInProgress.set(threadId, true); // Get thread data with retry - const threadData = yield* Effect.tryPromise(() => self.getWithRetry(threadId)).pipe( + const threadData = yield* Effect.tryPromise(() => this.getWithRetry(threadId)).pipe( Effect.tap(() => Effect.sync(() => console.log(`[syncThread] Retrieved thread data for ${threadId}`)), ), @@ -887,7 +883,7 @@ export class ZeroDriver extends AIChatAgent { const latest = threadData.latest; if (!latest) { - self.syncThreadsInProgress.delete(threadId); + this.syncThreadsInProgress.delete(threadId); console.log(`[syncThread] Skipping thread ${threadId} - no latest message`); result.success = false; result.reason = 'No latest message'; @@ -913,7 +909,7 @@ export class ZeroDriver extends AIChatAgent { // Store thread data in bucket yield* Effect.tryPromise(() => - env.THREADS_BUCKET.put(self.getThreadKey(threadId), JSON.stringify(threadData), { + env.THREADS_BUCKET.put(this.getThreadKey(threadId), JSON.stringify(threadData), { customMetadata: { threadId }, }), ).pipe( @@ -933,7 +929,7 @@ export class ZeroDriver extends AIChatAgent { // Update database yield* Effect.tryPromise(() => - Promise.resolve(self.sql` + Promise.resolve(this.sql` INSERT OR REPLACE INTO threads ( id, thread_id, @@ -965,9 +961,9 @@ export class ZeroDriver extends AIChatAgent { ); // Broadcast update if agent exists - if (self.agent) { + if (this.agent) { yield* Effect.tryPromise(() => - self.agent!.broadcastChatMessage({ + this.agent!.broadcastChatMessage({ type: OutgoingMessageType.Mail_Get, threadId, }), @@ -987,7 +983,7 @@ export class ZeroDriver extends AIChatAgent { console.log(`[syncThread] No agent available for broadcasting ${threadId}`); } - self.syncThreadsInProgress.delete(threadId); + this.syncThreadsInProgress.delete(threadId); result.success = true; result.threadData = threadData; @@ -1001,7 +997,7 @@ export class ZeroDriver extends AIChatAgent { return result; }).pipe( Effect.catchAll((error) => { - self.syncThreadsInProgress.delete(threadId); + this.syncThreadsInProgress.delete(threadId); console.error(`[syncThread] Critical error syncing thread ${threadId}:`, error); return Effect.succeed({ success: false, @@ -1027,8 +1023,6 @@ export class ZeroDriver extends AIChatAgent { } async syncThreads(folder: string): Promise { - const self = this; - if (!this.driver) { console.error(`[syncThreads] No driver available for folder ${folder}`); return { @@ -1058,7 +1052,7 @@ export class ZeroDriver extends AIChatAgent { } return Effect.runPromise( - Effect.gen(function* () { + Effect.gen(this, function* () { console.log(`[syncThreads] Starting sync for folder: ${folder}`); const result: FolderSyncResult = { @@ -1073,7 +1067,7 @@ export class ZeroDriver extends AIChatAgent { }; // Check thread count - const threadCount = yield* Effect.tryPromise(() => self.getThreadCount()).pipe( + const threadCount = yield* Effect.tryPromise(() => this.getThreadCount()).pipe( Effect.tap((count) => Effect.sync(() => console.log(`[syncThreads] Current thread count: ${count}`)), ), @@ -1089,13 +1083,13 @@ export class ZeroDriver extends AIChatAgent { return result; } - self.foldersInSync.set(folder, true); + this.foldersInSync.set(folder, true); // Sync single thread function const syncSingleThread = (threadId: string) => - Effect.gen(function* () { + Effect.gen(this, function* () { yield* Effect.sleep(150); // Rate limiting delay - const syncResult = yield* Effect.tryPromise(() => self.syncThread({ threadId })).pipe( + const syncResult = yield* Effect.tryPromise(() => this.syncThread({ threadId })).pipe( Effect.tap(() => Effect.sync(() => console.log(`[syncThreads] Successfully synced thread ${threadId}`), @@ -1136,7 +1130,7 @@ export class ZeroDriver extends AIChatAgent { ); const listResult = yield* Effect.tryPromise(() => - self.listWithRetry({ + this.listWithRetry({ folder, maxResults: maxCount, pageToken: pageToken || undefined, @@ -1183,9 +1177,9 @@ export class ZeroDriver extends AIChatAgent { } // Broadcast completion if agent exists - if (self.agent) { + if (this.agent) { yield* Effect.tryPromise(() => - self.agent!.broadcastChatMessage({ + this.agent!.broadcastChatMessage({ type: OutgoingMessageType.Mail_List, folder, }), @@ -1208,7 +1202,7 @@ export class ZeroDriver extends AIChatAgent { console.log(`[syncThreads] No agent available for broadcasting folder ${folder}`); } - self.foldersInSync.delete(folder); + this.foldersInSync.delete(folder); console.log(`[syncThreads] Completed sync for folder: ${folder}`, { synced: result.synced, @@ -1222,7 +1216,7 @@ export class ZeroDriver extends AIChatAgent { return result; }).pipe( Effect.catchAll((error) => { - self.foldersInSync.delete(folder); + this.foldersInSync.delete(folder); console.error(`[syncThreads] Critical error syncing folder ${folder}:`, error); return Effect.succeed({ synced: 0, diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts index d5fad8e127..b3d422b58e 100644 --- a/apps/server/src/thread-workflow-utils/workflow-functions.ts +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -9,6 +9,7 @@ import { EPrompts, defaultLabels, type ParsedMessage } from '../types'; import { getPrompt, getEmbeddingVector } from '../pipelines.effect'; import { messageToXML, threadToXML } from './workflow-utils'; import type { WorkflowContext } from './workflow-engine'; +import { bulkDeleteKeys } from '../lib/bulk-delete'; import { getZeroAgent } from '../lib/server-utils'; import { getPromptName } from '../pipelines'; import { env } from 'cloudflare:workers'; @@ -299,10 +300,12 @@ export const workflowFunctions: Record = { cleanupWorkflowExecution: async (context) => { const workflowKey = `workflow_${context.threadId}`; - await env.gmail_processing_threads.delete(workflowKey); + const result = await bulkDeleteKeys([workflowKey]); console.log( '[WORKFLOW_FUNCTIONS] Cleaned up workflow execution tracking for thread:', context.threadId, + 'Result:', + result, ); return { cleaned: true }; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1815295b40..2b6020e274 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,6 +573,9 @@ importers: cheerio: specifier: 1.1.0 version: 1.1.0 + cloudflare: + specifier: 4.5.0 + version: 4.5.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -4721,6 +4724,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cloudflare@4.5.0: + resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -13074,6 +13080,18 @@ snapshots: dependencies: clsx: 2.1.1 + cloudflare@4.5.0: + dependencies: + '@types/node': 18.19.115 + '@types/node-fetch': 2.6.12 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + clsx@2.1.1: {} cmdk@0.2.1(@types/react@19.0.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): From 88074599965439b084fa5182aec191a7a38263a0 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:28:18 -0700 Subject: [PATCH 19/48] Integrate Sentry error monitoring for Cloudflare worker environment (#1863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added Sentry error monitoring to the Cloudflare worker environment to capture and report runtime errors. - **Dependencies** - Installed the @sentry/cloudflare package and updated configuration for Sentry integration. --- apps/server/package.json | 1 + apps/server/src/main.ts | 661 ++++++++++++++++++++------------------- pnpm-lock.yaml | 25 ++ 3 files changed, 357 insertions(+), 330 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 7eae5c0d34..8f4d256fb5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,6 +38,7 @@ "@modelcontextprotocol/sdk": "1.15.1", "@react-email/components": "^0.0.41", "@react-email/render": "1.1.0", + "@sentry/cloudflare": "9.43.0", "@trpc/client": "catalog:", "@trpc/server": "catalog:", "@tsndr/cloudflare-worker-jwt": "3.2.0", diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1fc263736b..5490341ef0 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -14,8 +14,8 @@ import { userSettings, writingStyleMatrix, } from './db/schema'; -import { env, WorkerEntrypoint, DurableObject, RpcTarget } from 'cloudflare:workers'; import { EProviders, type ISubscribeBatch, type IThreadBatch } from './types'; +import { env, DurableObject, RpcTarget } from 'cloudflare:workers'; import { oAuthDiscoveryMetadata } from 'better-auth/plugins'; import { getZeroDB, verifyToken } from './lib/server-utils'; import { eq, and, desc, asc, inArray } from 'drizzle-orm'; @@ -26,6 +26,7 @@ import { defaultUserSettings } from './lib/schemas'; import { createLocalJWKSet, jwtVerify } from 'jose'; import { getZeroAgent } from './lib/server-utils'; import { enableBrainFunction } from './lib/brain'; +import { withSentry } from '@sentry/cloudflare'; import { trpcServer } from '@hono/trpc-server'; import { agentsMiddleware } from 'hono-agents'; import { ZeroMCP } from './routes/agent/mcp'; @@ -39,7 +40,6 @@ import { aiRouter } from './routes/ai'; import { Autumn } from 'autumn-js'; import { appRouter } from './trpc'; import { cors } from 'hono/cors'; - import { Hono } from 'hono'; const SENTRY_HOST = 'o4509328786915328.ingest.us.sentry.io'; @@ -497,370 +497,371 @@ class ZeroDB extends DurableObject { } } -export default class extends WorkerEntrypoint { - db: DB | undefined; - private api = new Hono() - .use(contextStorage()) - .use('*', async (c, next) => { - const auth = createAuth(); - c.set('auth', auth); - const session = await auth.api.getSession({ headers: c.req.raw.headers }); - c.set('sessionUser', session?.user); +const api = new Hono() + .use(contextStorage()) + .use('*', async (c, next) => { + const auth = createAuth(); + c.set('auth', auth); + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + c.set('sessionUser', session?.user); - if (c.req.header('Authorization') && !session?.user) { - const token = c.req.header('Authorization')?.split(' ')[1]; + if (c.req.header('Authorization') && !session?.user) { + const token = c.req.header('Authorization')?.split(' ')[1]; - if (token) { - const localJwks = await auth.api.getJwks(); - const jwks = createLocalJWKSet(localJwks); + if (token) { + const localJwks = await auth.api.getJwks(); + const jwks = createLocalJWKSet(localJwks); - const { payload } = await jwtVerify(token, jwks); - const userId = payload.sub; + const { payload } = await jwtVerify(token, jwks); + const userId = payload.sub; - if (userId) { - const db = await getZeroDB(userId); - c.set('sessionUser', await db.findUser()); - } + if (userId) { + const db = await getZeroDB(userId); + c.set('sessionUser', await db.findUser()); } } + } - const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }); - c.set('autumn', autumn); - - await next(); - - c.set('sessionUser', undefined); - c.set('autumn', undefined as any); - c.set('auth', undefined as any); - }) - .route('/ai', aiRouter) - .route('/autumn', autumnApi) - .route('/public', publicRouter) - .on(['GET', 'POST', 'OPTIONS'], '/auth/*', (c) => { - return c.var.auth.handler(c.req.raw); - }) - .use( - trpcServer({ - endpoint: '/api/trpc', - router: appRouter, - createContext: (_, c) => { - return { c, sessionUser: c.var['sessionUser'], db: c.var['db'] }; - }, - allowMethodOverride: true, - onError: (opts) => { - console.error('Error in TRPC handler:', opts.error); - }, - }), - ) - .onError(async (err, c) => { - if (err instanceof Response) return err; - console.error('Error in Hono handler:', err); - return c.json( - { - error: 'Internal Server Error', - message: err instanceof Error ? err.message : 'Unknown error', - }, - 500, - ); - }); - - private app = new Hono() - .use( - '*', - cors({ - origin: (origin) => { - if (!origin) return null; - let hostname: string; - try { - hostname = new URL(origin).hostname; - } catch { - return null; - } - const cookieDomain = env.COOKIE_DOMAIN; - if (!cookieDomain) return null; - if (hostname === cookieDomain || hostname.endsWith('.' + cookieDomain)) { - return origin; - } - return null; - }, - credentials: true, - allowHeaders: ['Content-Type', 'Authorization'], - exposeHeaders: ['X-Zero-Redirect'], - }), - ) - .get('.well-known/oauth-authorization-server', async (c) => { - const auth = createAuth(); - return oAuthDiscoveryMetadata(auth)(c.req.raw); - }) - .mount( - '/sse', - async (request, env, ctx) => { - const authBearer = request.headers.get('Authorization'); - if (!authBearer) { - console.log('No auth provided'); - return new Response('Unauthorized', { status: 401 }); - } - const auth = createAuth(); - const session = await auth.api.getMcpSession({ headers: request.headers }); - if (!session) { - console.log('Invalid auth provided', Array.from(request.headers.entries())); - return new Response('Unauthorized', { status: 401 }); - } - ctx.props = { - userId: session?.userId, - }; - return ZeroMCP.serveSSE('/sse', { binding: 'ZERO_MCP' }).fetch(request, env, ctx); + const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }); + c.set('autumn', autumn); + + await next(); + + c.set('sessionUser', undefined); + c.set('autumn', undefined as any); + c.set('auth', undefined as any); + }) + .route('/ai', aiRouter) + .route('/autumn', autumnApi) + .route('/public', publicRouter) + .on(['GET', 'POST', 'OPTIONS'], '/auth/*', (c) => { + return c.var.auth.handler(c.req.raw); + }) + .use( + trpcServer({ + endpoint: '/api/trpc', + router: appRouter, + createContext: (_, c) => { + return { c, sessionUser: c.var['sessionUser'], db: c.var['db'] }; }, - { replaceRequest: false }, - ) - .mount( - '/mcp/thinking/sse', - async (request, env, ctx) => { - return ThinkingMCP.serveSSE('/mcp/thinking/sse', { binding: 'THINKING_MCP' }).fetch( - request, - env, - ctx, - ); + allowMethodOverride: true, + onError: (opts) => { + console.error('Error in TRPC handler:', opts.error); + }, + }), + ) + .onError(async (err, c) => { + if (err instanceof Response) return err; + console.error('Error in Hono handler:', err); + return c.json( + { + error: 'Internal Server Error', + message: err instanceof Error ? err.message : 'Unknown error', }, - { replaceRequest: false }, - ) - .mount( - '/mcp', - async (request, env, ctx) => { - const authBearer = request.headers.get('Authorization'); - if (!authBearer) { - return new Response('Unauthorized', { status: 401 }); + 500, + ); + }); + +const app = new Hono() + .use( + '*', + cors({ + origin: (origin) => { + if (!origin) return null; + let hostname: string; + try { + hostname = new URL(origin).hostname; + } catch { + return null; } - const auth = createAuth(); - const session = await auth.api.getMcpSession({ headers: request.headers }); - if (!session) { - console.log('Invalid auth provided', Array.from(request.headers.entries())); - return new Response('Unauthorized', { status: 401 }); + const cookieDomain = env.COOKIE_DOMAIN; + if (!cookieDomain) return null; + if (hostname === cookieDomain || hostname.endsWith('.' + cookieDomain)) { + return origin; } - ctx.props = { - userId: session?.userId, - }; - return ZeroMCP.serve('/mcp', { binding: 'ZERO_MCP' }).fetch(request, env, ctx); + return null; }, - { replaceRequest: false }, - ) - .route('/api', this.api) - .use( - '*', - agentsMiddleware({ - options: { - onBeforeConnect: (c) => { - if (!c.headers.get('Cookie')) { - return new Response('Unauthorized', { status: 401 }); - } - }, + credentials: true, + allowHeaders: ['Content-Type', 'Authorization'], + exposeHeaders: ['X-Zero-Redirect'], + }), + ) + .get('.well-known/oauth-authorization-server', async (c) => { + const auth = createAuth(); + return oAuthDiscoveryMetadata(auth)(c.req.raw); + }) + .mount( + '/sse', + async (request, env, ctx) => { + const authBearer = request.headers.get('Authorization'); + if (!authBearer) { + console.log('No auth provided'); + return new Response('Unauthorized', { status: 401 }); + } + const auth = createAuth(); + const session = await auth.api.getMcpSession({ headers: request.headers }); + if (!session) { + console.log('Invalid auth provided', Array.from(request.headers.entries())); + return new Response('Unauthorized', { status: 401 }); + } + ctx.props = { + userId: session?.userId, + }; + return ZeroMCP.serveSSE('/sse', { binding: 'ZERO_MCP' }).fetch(request, env, ctx); + }, + { replaceRequest: false }, + ) + .mount( + '/mcp/thinking/sse', + async (request, env, ctx) => { + return ThinkingMCP.serveSSE('/mcp/thinking/sse', { binding: 'THINKING_MCP' }).fetch( + request, + env, + ctx, + ); + }, + { replaceRequest: false }, + ) + .mount( + '/mcp', + async (request, env, ctx) => { + const authBearer = request.headers.get('Authorization'); + if (!authBearer) { + return new Response('Unauthorized', { status: 401 }); + } + const auth = createAuth(); + const session = await auth.api.getMcpSession({ headers: request.headers }); + if (!session) { + console.log('Invalid auth provided', Array.from(request.headers.entries())); + return new Response('Unauthorized', { status: 401 }); + } + ctx.props = { + userId: session?.userId, + }; + return ZeroMCP.serve('/mcp', { binding: 'ZERO_MCP' }).fetch(request, env, ctx); + }, + { replaceRequest: false }, + ) + .route('/api', api) + .use( + '*', + agentsMiddleware({ + options: { + onBeforeConnect: (c) => { + if (!c.headers.get('Cookie')) { + return new Response('Unauthorized', { status: 401 }); + } }, - }), - ) - .get('/health', (c) => c.json({ message: 'Zero Server is Up!' })) - .get('/', (c) => c.redirect(`${env.VITE_PUBLIC_APP_URL}`)) - .post('/monitoring/sentry', async (c) => { - try { - const envelopeBytes = await c.req.arrayBuffer(); - const envelope = new TextDecoder().decode(envelopeBytes); - const piece = envelope.split('\n')[0]; - const header = JSON.parse(piece); - const dsn = new URL(header['dsn']); - const project_id = dsn.pathname?.replace('/', ''); - - if (dsn.hostname !== SENTRY_HOST) { - throw new Error(`Invalid sentry hostname: ${dsn.hostname}`); - } + }, + }), + ) + .get('/health', (c) => c.json({ message: 'Zero Server is Up!' })) + .get('/', (c) => c.redirect(`${env.VITE_PUBLIC_APP_URL}`)) + .post('/monitoring/sentry', async (c) => { + try { + const envelopeBytes = await c.req.arrayBuffer(); + const envelope = new TextDecoder().decode(envelopeBytes); + const piece = envelope.split('\n')[0]; + const header = JSON.parse(piece); + const dsn = new URL(header['dsn']); + const project_id = dsn.pathname?.replace('/', ''); + + if (dsn.hostname !== SENTRY_HOST) { + throw new Error(`Invalid sentry hostname: ${dsn.hostname}`); + } - if (!project_id || !SENTRY_PROJECT_IDS.has(project_id)) { - throw new Error(`Invalid sentry project id: ${project_id}`); - } + if (!project_id || !SENTRY_PROJECT_IDS.has(project_id)) { + throw new Error(`Invalid sentry project id: ${project_id}`); + } - const upstream_sentry_url = `https://${SENTRY_HOST}/api/${project_id}/envelope/`; - await fetch(upstream_sentry_url, { - method: 'POST', - body: envelopeBytes, - }); + const upstream_sentry_url = `https://${SENTRY_HOST}/api/${project_id}/envelope/`; + await fetch(upstream_sentry_url, { + method: 'POST', + body: envelopeBytes, + }); + return c.json({}, { status: 200 }); + } catch (e) { + console.error('error tunneling to sentry', e); + return c.json({ error: 'error tunneling to sentry' }, { status: 500 }); + } + }) + .post('/a8n/notify/:providerId', async (c) => { + if (!c.req.header('Authorization')) return c.json({ error: 'Unauthorized' }, { status: 401 }); + if (env.DISABLE_WORKFLOWS === 'true') return c.json({ message: 'OK' }, { status: 200 }); + const providerId = c.req.param('providerId'); + if (providerId === EProviders.google) { + const body = await c.req.json<{ historyId: string }>(); + const subHeader = c.req.header('x-goog-pubsub-subscription-name'); + if (!subHeader) { + console.log('[GOOGLE] no subscription header', body); return c.json({}, { status: 200 }); - } catch (e) { - console.error('error tunneling to sentry', e); - return c.json({ error: 'error tunneling to sentry' }, { status: 500 }); } - }) - .post('/a8n/notify/:providerId', async (c) => { - if (!c.req.header('Authorization')) return c.json({ error: 'Unauthorized' }, { status: 401 }); - if (env.DISABLE_WORKFLOWS === 'true') return c.json({ message: 'OK' }, { status: 200 }); - const providerId = c.req.param('providerId'); - if (providerId === EProviders.google) { - const body = await c.req.json<{ historyId: string }>(); - const subHeader = c.req.header('x-goog-pubsub-subscription-name'); - if (!subHeader) { - console.log('[GOOGLE] no subscription header', body); - return c.json({}, { status: 200 }); - } - const isValid = await verifyToken(c.req.header('Authorization')!.split(' ')[1]); - if (!isValid) { - console.log('[GOOGLE] invalid request', body); - return c.json({}, { status: 200 }); - } - try { - await env.thread_queue.send({ - providerId, - historyId: body.historyId, - subscriptionName: subHeader, - }); - } catch (error) { - console.error('Error sending to thread queue', error, { - providerId, - historyId: body.historyId, - subscriptionName: subHeader, - }); - } - return c.json({ message: 'OK' }, { status: 200 }); - } - }); - - async fetch(request: Request): Promise { - return this.app.fetch(request, this.env, this.ctx); - } - - async queue(batch: MessageBatch) { - switch (true) { - case batch.queue.startsWith('subscribe-queue'): { - console.log('batch', batch); - await Promise.all( - batch.messages.map(async (msg: Message) => { - const connectionId = msg.body.connectionId; - const providerId = msg.body.providerId; - try { - await enableBrainFunction({ id: connectionId, providerId }); - } catch (error) { - console.error( - `Failed to enable brain function for connection ${connectionId}:`, - error, - ); - } - }), - ); - console.log('[SUBSCRIBE_QUEUE] batch done'); - return; + const isValid = await verifyToken(c.req.header('Authorization')!.split(' ')[1]); + if (!isValid) { + console.log('[GOOGLE] invalid request', body); + return c.json({}, { status: 200 }); } - case batch.queue.startsWith('thread-queue'): { - await Promise.all( - batch.messages.map(async (msg: Message) => { - const providerId = msg.body.providerId; - const historyId = msg.body.historyId; - const subscriptionName = msg.body.subscriptionName; - - try { - const workflowRunner = env.WORKFLOW_RUNNER.get(env.WORKFLOW_RUNNER.newUniqueId()); - const result = await workflowRunner.runMainWorkflow({ - providerId, - historyId, - subscriptionName, - }); - console.log('[THREAD_QUEUE] result', result); - } catch (error) { - console.error('Error running workflow', error); - } - }), - ); - break; + try { + await env.thread_queue.send({ + providerId, + historyId: body.historyId, + subscriptionName: subHeader, + }); + } catch (error) { + console.error('Error sending to thread queue', error, { + providerId, + historyId: body.historyId, + subscriptionName: subHeader, + }); } + return c.json({ message: 'OK' }, { status: 200 }); } - } - - async scheduled() { - console.log('[SCHEDULED] Checking for expired subscriptions...'); - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); - const allAccounts = await db.query.connection.findMany({ - where: (fields, { isNotNull, and }) => - and(isNotNull(fields.accessToken), isNotNull(fields.refreshToken)), - }); - await conn.end(); - console.log('[SCHEDULED] allAccounts', allAccounts.length); - const now = new Date(); - const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + }); +export default withSentry( + () => ({ + dsn: 'https://54d9ec6795f10e5c6d1c4851523d4888@o4509328786915328.ingest.us.sentry.io/4509753563938816', + }), + { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return app.fetch(request, env, ctx); + }, + async queue(batch: MessageBatch) { + switch (true) { + case batch.queue.startsWith('subscribe-queue'): { + console.log('batch', batch); + await Promise.all( + batch.messages.map(async (msg: Message) => { + const connectionId = msg.body.connectionId; + const providerId = msg.body.providerId; + try { + await enableBrainFunction({ id: connectionId, providerId }); + } catch (error) { + console.error( + `Failed to enable brain function for connection ${connectionId}:`, + error, + ); + } + }), + ); + console.log('[SUBSCRIBE_QUEUE] batch done'); + return; + } + case batch.queue.startsWith('thread-queue'): { + await Promise.all( + batch.messages.map(async (msg: Message) => { + const providerId = msg.body.providerId; + const historyId = msg.body.historyId; + const subscriptionName = msg.body.subscriptionName; + + try { + const workflowRunner = env.WORKFLOW_RUNNER.get(env.WORKFLOW_RUNNER.newUniqueId()); + const result = await workflowRunner.runMainWorkflow({ + providerId, + historyId, + subscriptionName, + }); + console.log('[THREAD_QUEUE] result', result); + } catch (error) { + console.error('Error running workflow', error); + } + }), + ); + break; + } + } + }, + async scheduled() { + console.log('[SCHEDULED] Checking for expired subscriptions...'); + const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + const allAccounts = await db.query.connection.findMany({ + where: (fields, { isNotNull, and }) => + and(isNotNull(fields.accessToken), isNotNull(fields.refreshToken)), + }); + await conn.end(); + console.log('[SCHEDULED] allAccounts', allAccounts.length); + const now = new Date(); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const expiredSubscriptions: Array<{ connectionId: string; providerId: EProviders }> = []; + const expiredSubscriptions: Array<{ connectionId: string; providerId: EProviders }> = []; - const nowTs = Date.now(); + const nowTs = Date.now(); - const unsnoozeMap: Record = {}; + const unsnoozeMap: Record = {}; - let cursor: string | undefined = undefined; - do { - const listResp: { - keys: { name: string; metadata?: { wakeAt?: string } }[]; - cursor?: string; - } = await env.snoozed_emails.list({ cursor, limit: 1000 }); - cursor = listResp.cursor; + let cursor: string | undefined = undefined; + do { + const listResp: { + keys: { name: string; metadata?: { wakeAt?: string } }[]; + cursor?: string; + } = await env.snoozed_emails.list({ cursor, limit: 1000 }); + cursor = listResp.cursor; - for (const key of listResp.keys) { - try { - const wakeAtIso = (key as any).metadata?.wakeAt as string | undefined; - if (!wakeAtIso) continue; - const wakeAt = new Date(wakeAtIso).getTime(); - if (wakeAt > nowTs) continue; + for (const key of listResp.keys) { + try { + const wakeAtIso = (key as any).metadata?.wakeAt as string | undefined; + if (!wakeAtIso) continue; + const wakeAt = new Date(wakeAtIso).getTime(); + if (wakeAt > nowTs) continue; - const [threadId, connectionId] = key.name.split('__'); - if (!threadId || !connectionId) continue; + const [threadId, connectionId] = key.name.split('__'); + if (!threadId || !connectionId) continue; - if (!unsnoozeMap[connectionId]) { - unsnoozeMap[connectionId] = { threadIds: [], keyNames: [] }; + if (!unsnoozeMap[connectionId]) { + unsnoozeMap[connectionId] = { threadIds: [], keyNames: [] }; + } + unsnoozeMap[connectionId].threadIds.push(threadId); + unsnoozeMap[connectionId].keyNames.push(key.name); + } catch (error) { + console.error('Failed to prepare unsnooze for key', key.name, error); } - unsnoozeMap[connectionId].threadIds.push(threadId); - unsnoozeMap[connectionId].keyNames.push(key.name); - } catch (error) { - console.error('Failed to prepare unsnooze for key', key.name, error); } - } - } while (cursor); + } while (cursor); - await Promise.all( - Object.entries(unsnoozeMap).map(async ([connectionId, { threadIds, keyNames }]) => { - try { - const agent = await getZeroAgent(connectionId); - await agent.queue('unsnoozeThreadsHandler', { connectionId, threadIds, keyNames }); - } catch (error) { - console.error('Failed to enqueue unsnooze tasks', { connectionId, threadIds, error }); - } - }), - ); - - await Promise.all( - allAccounts.map(async ({ id, providerId }) => { - const lastSubscribed = await env.gmail_sub_age.get(`${id}__${providerId}`); - - if (lastSubscribed) { - const subscriptionDate = new Date(lastSubscribed); - if (subscriptionDate < fiveDaysAgo) { - console.log(`[SCHEDULED] Found expired Google subscription for connection: ${id}`); - expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); + await Promise.all( + Object.entries(unsnoozeMap).map(async ([connectionId, { threadIds, keyNames }]) => { + try { + const agent = await getZeroAgent(connectionId); + await agent.queue('unsnoozeThreadsHandler', { connectionId, threadIds, keyNames }); + } catch (error) { + console.error('Failed to enqueue unsnooze tasks', { connectionId, threadIds, error }); } - } else { - expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); - } - }), - ); - - // Send expired subscriptions to queue for renewal - if (expiredSubscriptions.length > 0) { - console.log( - `[SCHEDULED] Sending ${expiredSubscriptions.length} expired subscriptions to renewal queue`, + }), ); + await Promise.all( - expiredSubscriptions.map(async ({ connectionId, providerId }) => { - await env.subscribe_queue.send({ connectionId, providerId }); + allAccounts.map(async ({ id, providerId }) => { + const lastSubscribed = await env.gmail_sub_age.get(`${id}__${providerId}`); + + if (lastSubscribed) { + const subscriptionDate = new Date(lastSubscribed); + if (subscriptionDate < fiveDaysAgo) { + console.log(`[SCHEDULED] Found expired Google subscription for connection: ${id}`); + expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); + } + } else { + expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); + } }), ); - } - console.log( - `[SCHEDULED] Processed ${allAccounts.keys.length} accounts, found ${expiredSubscriptions.length} expired subscriptions`, - ); - } -} + // Send expired subscriptions to queue for renewal + if (expiredSubscriptions.length > 0) { + console.log( + `[SCHEDULED] Sending ${expiredSubscriptions.length} expired subscriptions to renewal queue`, + ); + await Promise.all( + expiredSubscriptions.map(async ({ connectionId, providerId }) => { + await env.subscribe_queue.send({ connectionId, providerId }); + }), + ); + } + + console.log( + `[SCHEDULED] Processed ${allAccounts.keys.length} accounts, found ${expiredSubscriptions.length} expired subscriptions`, + ); + }, + } satisfies ExportedHandler, +); export { ZeroAgent, ZeroMCP, ZeroDB, ZeroDriver, ThinkingMCP, WorkflowRunner }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b6020e274..2684d5e035 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,9 @@ importers: '@react-email/render': specifier: 1.1.0 version: 1.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@sentry/cloudflare': + specifier: 9.43.0 + version: 9.43.0(@cloudflare/workers-types@4.20250628.0) '@trpc/client': specifier: 'catalog:' version: 11.4.3(@trpc/server@11.4.3(typescript@5.8.3))(typescript@5.8.3) @@ -3721,10 +3724,23 @@ packages: engines: {node: '>= 10'} hasBin: true + '@sentry/cloudflare@9.43.0': + resolution: {integrity: sha512-WZsNP62qPaWGx55tPHPFm7y7kGNAnT89YL6M4fNzqugy2tB5NPKbIxUSdnROnhQ02eHYla6Lc7+Y1YrhJ05UTw==} + engines: {node: '>=18'} + peerDependencies: + '@cloudflare/workers-types': ^4.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@sentry/core@9.40.0': resolution: {integrity: sha512-cZkuz6BDna6VXSqvlWnrRsaDx4QBKq1PcfQrqhVz8ljs0M7Gcl+Mtj8dCzUxx12fkYM62hQXG72DEGNlAQpH/Q==} engines: {node: '>=18'} + '@sentry/core@9.43.0': + resolution: {integrity: sha512-xuvERSUkSNBAldIlgihX3fz+JkcaAPvg0HulPtv3BH9qrKqvataeQ8TiTnqiRC7kWzF7EcxhQJ6WJRl/r3aH3w==} + engines: {node: '>=18'} + '@sentry/node-core@9.40.0': resolution: {integrity: sha512-97JONDa8NxItX0Cz5WQPMd1gQjzodt38qQ0OzZNFvYg2Cpvxob8rxwsNA08Liu7B97rlvsvqMt+Wbgw8SAMfgQ==} engines: {node: '>=18'} @@ -11870,8 +11886,17 @@ snapshots: - encoding - supports-color + '@sentry/cloudflare@9.43.0(@cloudflare/workers-types@4.20250628.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@sentry/core': 9.43.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20250628.0 + '@sentry/core@9.40.0': {} + '@sentry/core@9.43.0': {} + '@sentry/node-core@9.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': dependencies: '@opentelemetry/api': 1.9.0 From 091bb5df39acfa6a8482b5643e470e44ac85a027 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:02:19 -0700 Subject: [PATCH 20/48] Replace Sentry with Cloudflare WorkerEntrypoint class (#1865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Replaced Sentry integration with a Cloudflare WorkerEntrypoint class to handle fetch, queue, and scheduled events directly. - **Refactors** - Removed withSentry wrapper and migrated all event handling logic into a WorkerEntrypoint class. --- apps/server/src/main.ts | 254 ++++++++++++++++++++-------------------- 1 file changed, 124 insertions(+), 130 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5490341ef0..a2f6c835fd 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -14,8 +14,8 @@ import { userSettings, writingStyleMatrix, } from './db/schema'; +import { env, DurableObject, RpcTarget, WorkerEntrypoint } from 'cloudflare:workers'; import { EProviders, type ISubscribeBatch, type IThreadBatch } from './types'; -import { env, DurableObject, RpcTarget } from 'cloudflare:workers'; import { oAuthDiscoveryMetadata } from 'better-auth/plugins'; import { getZeroDB, verifyToken } from './lib/server-utils'; import { eq, and, desc, asc, inArray } from 'drizzle-orm'; @@ -26,7 +26,6 @@ import { defaultUserSettings } from './lib/schemas'; import { createLocalJWKSet, jwtVerify } from 'jose'; import { getZeroAgent } from './lib/server-utils'; import { enableBrainFunction } from './lib/brain'; -import { withSentry } from '@sentry/cloudflare'; import { trpcServer } from '@hono/trpc-server'; import { agentsMiddleware } from 'hono-agents'; import { ZeroMCP } from './routes/agent/mcp'; @@ -718,150 +717,145 @@ const app = new Hono() return c.json({ message: 'OK' }, { status: 200 }); } }); -export default withSentry( - () => ({ - dsn: 'https://54d9ec6795f10e5c6d1c4851523d4888@o4509328786915328.ingest.us.sentry.io/4509753563938816', - }), - { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - return app.fetch(request, env, ctx); - }, - async queue(batch: MessageBatch) { - switch (true) { - case batch.queue.startsWith('subscribe-queue'): { - console.log('batch', batch); - await Promise.all( - batch.messages.map(async (msg: Message) => { - const connectionId = msg.body.connectionId; - const providerId = msg.body.providerId; - try { - await enableBrainFunction({ id: connectionId, providerId }); - } catch (error) { - console.error( - `Failed to enable brain function for connection ${connectionId}:`, - error, - ); - } - }), - ); - console.log('[SUBSCRIBE_QUEUE] batch done'); - return; - } - case batch.queue.startsWith('thread-queue'): { - await Promise.all( - batch.messages.map(async (msg: Message) => { - const providerId = msg.body.providerId; - const historyId = msg.body.historyId; - const subscriptionName = msg.body.subscriptionName; - - try { - const workflowRunner = env.WORKFLOW_RUNNER.get(env.WORKFLOW_RUNNER.newUniqueId()); - const result = await workflowRunner.runMainWorkflow({ - providerId, - historyId, - subscriptionName, - }); - console.log('[THREAD_QUEUE] result', result); - } catch (error) { - console.error('Error running workflow', error); - } - }), - ); - break; - } +export default class Entry extends WorkerEntrypoint { + async fetch(request: Request): Promise { + return app.fetch(request, this.env, this.ctx); + } + async queue(batch: MessageBatch) { + switch (true) { + case batch.queue.startsWith('subscribe-queue'): { + console.log('batch', batch); + await Promise.all( + batch.messages.map(async (msg: Message) => { + const connectionId = msg.body.connectionId; + const providerId = msg.body.providerId; + try { + await enableBrainFunction({ id: connectionId, providerId }); + } catch (error) { + console.error( + `Failed to enable brain function for connection ${connectionId}:`, + error, + ); + } + }), + ); + console.log('[SUBSCRIBE_QUEUE] batch done'); + return; } - }, - async scheduled() { - console.log('[SCHEDULED] Checking for expired subscriptions...'); - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); - const allAccounts = await db.query.connection.findMany({ - where: (fields, { isNotNull, and }) => - and(isNotNull(fields.accessToken), isNotNull(fields.refreshToken)), - }); - await conn.end(); - console.log('[SCHEDULED] allAccounts', allAccounts.length); - const now = new Date(); - const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + case batch.queue.startsWith('thread-queue'): { + await Promise.all( + batch.messages.map(async (msg: Message) => { + const providerId = msg.body.providerId; + const historyId = msg.body.historyId; + const subscriptionName = msg.body.subscriptionName; + + try { + const workflowRunner = env.WORKFLOW_RUNNER.get(env.WORKFLOW_RUNNER.newUniqueId()); + const result = await workflowRunner.runMainWorkflow({ + providerId, + historyId, + subscriptionName, + }); + console.log('[THREAD_QUEUE] result', result); + } catch (error) { + console.error('Error running workflow', error); + } + }), + ); + break; + } + } + } + async scheduled() { + console.log('[SCHEDULED] Checking for expired subscriptions...'); + const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + const allAccounts = await db.query.connection.findMany({ + where: (fields, { isNotNull, and }) => + and(isNotNull(fields.accessToken), isNotNull(fields.refreshToken)), + }); + await conn.end(); + console.log('[SCHEDULED] allAccounts', allAccounts.length); + const now = new Date(); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const expiredSubscriptions: Array<{ connectionId: string; providerId: EProviders }> = []; + const expiredSubscriptions: Array<{ connectionId: string; providerId: EProviders }> = []; - const nowTs = Date.now(); + const nowTs = Date.now(); - const unsnoozeMap: Record = {}; + const unsnoozeMap: Record = {}; - let cursor: string | undefined = undefined; - do { - const listResp: { - keys: { name: string; metadata?: { wakeAt?: string } }[]; - cursor?: string; - } = await env.snoozed_emails.list({ cursor, limit: 1000 }); - cursor = listResp.cursor; + let cursor: string | undefined = undefined; + do { + const listResp: { + keys: { name: string; metadata?: { wakeAt?: string } }[]; + cursor?: string; + } = await env.snoozed_emails.list({ cursor, limit: 1000 }); + cursor = listResp.cursor; - for (const key of listResp.keys) { - try { - const wakeAtIso = (key as any).metadata?.wakeAt as string | undefined; - if (!wakeAtIso) continue; - const wakeAt = new Date(wakeAtIso).getTime(); - if (wakeAt > nowTs) continue; + for (const key of listResp.keys) { + try { + const wakeAtIso = (key as any).metadata?.wakeAt as string | undefined; + if (!wakeAtIso) continue; + const wakeAt = new Date(wakeAtIso).getTime(); + if (wakeAt > nowTs) continue; - const [threadId, connectionId] = key.name.split('__'); - if (!threadId || !connectionId) continue; + const [threadId, connectionId] = key.name.split('__'); + if (!threadId || !connectionId) continue; - if (!unsnoozeMap[connectionId]) { - unsnoozeMap[connectionId] = { threadIds: [], keyNames: [] }; - } - unsnoozeMap[connectionId].threadIds.push(threadId); - unsnoozeMap[connectionId].keyNames.push(key.name); - } catch (error) { - console.error('Failed to prepare unsnooze for key', key.name, error); + if (!unsnoozeMap[connectionId]) { + unsnoozeMap[connectionId] = { threadIds: [], keyNames: [] }; } + unsnoozeMap[connectionId].threadIds.push(threadId); + unsnoozeMap[connectionId].keyNames.push(key.name); + } catch (error) { + console.error('Failed to prepare unsnooze for key', key.name, error); } - } while (cursor); + } + } while (cursor); - await Promise.all( - Object.entries(unsnoozeMap).map(async ([connectionId, { threadIds, keyNames }]) => { - try { - const agent = await getZeroAgent(connectionId); - await agent.queue('unsnoozeThreadsHandler', { connectionId, threadIds, keyNames }); - } catch (error) { - console.error('Failed to enqueue unsnooze tasks', { connectionId, threadIds, error }); - } - }), - ); + await Promise.all( + Object.entries(unsnoozeMap).map(async ([connectionId, { threadIds, keyNames }]) => { + try { + const agent = await getZeroAgent(connectionId); + await agent.queue('unsnoozeThreadsHandler', { connectionId, threadIds, keyNames }); + } catch (error) { + console.error('Failed to enqueue unsnooze tasks', { connectionId, threadIds, error }); + } + }), + ); - await Promise.all( - allAccounts.map(async ({ id, providerId }) => { - const lastSubscribed = await env.gmail_sub_age.get(`${id}__${providerId}`); - - if (lastSubscribed) { - const subscriptionDate = new Date(lastSubscribed); - if (subscriptionDate < fiveDaysAgo) { - console.log(`[SCHEDULED] Found expired Google subscription for connection: ${id}`); - expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); - } - } else { + await Promise.all( + allAccounts.map(async ({ id, providerId }) => { + const lastSubscribed = await env.gmail_sub_age.get(`${id}__${providerId}`); + + if (lastSubscribed) { + const subscriptionDate = new Date(lastSubscribed); + if (subscriptionDate < fiveDaysAgo) { + console.log(`[SCHEDULED] Found expired Google subscription for connection: ${id}`); expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); } - }), - ); - - // Send expired subscriptions to queue for renewal - if (expiredSubscriptions.length > 0) { - console.log( - `[SCHEDULED] Sending ${expiredSubscriptions.length} expired subscriptions to renewal queue`, - ); - await Promise.all( - expiredSubscriptions.map(async ({ connectionId, providerId }) => { - await env.subscribe_queue.send({ connectionId, providerId }); - }), - ); - } + } else { + expiredSubscriptions.push({ connectionId: id, providerId: providerId as EProviders }); + } + }), + ); + // Send expired subscriptions to queue for renewal + if (expiredSubscriptions.length > 0) { console.log( - `[SCHEDULED] Processed ${allAccounts.keys.length} accounts, found ${expiredSubscriptions.length} expired subscriptions`, + `[SCHEDULED] Sending ${expiredSubscriptions.length} expired subscriptions to renewal queue`, ); - }, - } satisfies ExportedHandler, -); + await Promise.all( + expiredSubscriptions.map(async ({ connectionId, providerId }) => { + await env.subscribe_queue.send({ connectionId, providerId }); + }), + ); + } + + console.log( + `[SCHEDULED] Processed ${allAccounts.keys.length} accounts, found ${expiredSubscriptions.length} expired subscriptions`, + ); + } +} export { ZeroAgent, ZeroMCP, ZeroDB, ZeroDriver, ThinkingMCP, WorkflowRunner }; From 67a4cbfd1b36763ae414a26ca43e1ce944600a72 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:04:50 -0700 Subject: [PATCH 21/48] Add local environment support to bulkDeleteKeys function (#1866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added support for running bulkDeleteKeys in local environments by deleting keys from the local store. --- apps/server/src/lib/bulk-delete.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/lib/bulk-delete.ts b/apps/server/src/lib/bulk-delete.ts index 76d429524f..9be9581906 100644 --- a/apps/server/src/lib/bulk-delete.ts +++ b/apps/server/src/lib/bulk-delete.ts @@ -25,6 +25,10 @@ export const bulkDeleteKeys = async ( keys: string[], environment: Environment = env.NODE_ENV as Environment, ): Promise => { + if (environment === 'local') { + await Promise.all(keys.map((key) => env.gmail_processing_threads.delete(key))); + return { successful: keys.length, failed: 0 }; + } if (keys.length === 0) { return { successful: 0, failed: 0 }; } From 762895a76f623b39c739fd7c39d05504e6d41967 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:13:25 -0700 Subject: [PATCH 22/48] Pass results to workflow step condition function (#1867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Updated the workflow engine to pass the results object to each step's condition function, allowing conditions to use previous step results. --- apps/server/src/thread-workflow-utils/workflow-engine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts index 792fe35650..dd7a639938 100644 --- a/apps/server/src/thread-workflow-utils/workflow-engine.ts +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -58,7 +58,7 @@ export class WorkflowEngine { } try { - const shouldExecute = step.condition ? await step.condition(context) : true; + const shouldExecute = step.condition ? await step.condition({ ...context, results }) : true; if (!shouldExecute) { console.log(`[WORKFLOW_ENGINE] Condition not met for step: ${step.name}`); continue; From 15bbf89c3ba3c280cbaf061630ed19a4d0a3f868 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:38:17 -0700 Subject: [PATCH 23/48] Add Effect-free workflow implementation for direct Cloudflare Workers execution (#1869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Added support for draft detection in email threads to improve the auto-draft generation workflow. This PR adds an `isLatestDraft` flag to thread responses, allowing the system to check if a draft already exists in a thread without making additional API calls. The workflow engine has been optimized to skip draft generation for threads that already have drafts, automated emails, or messages older than 7 days. Additionally, implemented non-Effect.ts versions of the workflow functions to provide an alternative implementation path that doesn't rely on the Effect library, which will help with testing and debugging. ## Type of Change - [x] ✨ New feature (non-breaking change which adds functionality) - [x] ⚡ Performance improvement ## Areas Affected - [x] Email Integration (Gmail, IMAP, etc.) - [x] Data Storage/Management ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have commented my code, particularly in complex areas ## Additional Notes The draft detection improvements reduce unnecessary API calls to check for existing drafts, which should improve performance and reduce the likelihood of hitting API rate limits. The workflow engine now also properly skips automated emails and old threads, focusing resources on generating drafts only for relevant conversations. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- apps/server/src/lib/driver/types.ts | 5 +- apps/server/src/pipelines.ts | 566 +++++++++++++++++- apps/server/src/routes/agent/index.ts | 36 +- apps/server/src/routes/agent/rpc.ts | 4 +- .../server/src/thread-workflow-utils/index.ts | 44 +- .../thread-workflow-utils/workflow-engine.ts | 79 +-- .../workflow-functions.ts | 30 +- apps/server/src/trpc/routes/mail.ts | 2 +- apps/server/wrangler.jsonc | 8 +- 9 files changed, 642 insertions(+), 132 deletions(-) diff --git a/apps/server/src/lib/driver/types.ts b/apps/server/src/lib/driver/types.ts index 07f909c3ea..48fd7354a1 100644 --- a/apps/server/src/lib/driver/types.ts +++ b/apps/server/src/lib/driver/types.ts @@ -9,6 +9,7 @@ export interface IGetThreadResponse { hasUnread: boolean; totalReplies: number; labels: { id: string; name: string }[]; + isLatestDraft?: boolean; } export const IGetThreadResponseSchema = z.object({ @@ -51,9 +52,7 @@ export type ManagerConfig = { export interface MailManager { config: ManagerConfig; - getMessageAttachments( - id: string, - ): Promise< + getMessageAttachments(id: string): Promise< { filename: string; mimeType: string; diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index 887aeeac67..355c428cc2 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -11,7 +11,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createDefaultWorkflows } from './thread-workflow-utils/workflow-engine'; +import { + createDefaultWorkflows, + type WorkflowContext, +} from './thread-workflow-utils/workflow-engine'; import { getServiceAccount } from './lib/factories/google-subscription.factory'; import { DurableObject } from 'cloudflare:workers'; import { bulkDeleteKeys } from './lib/bulk-delete'; @@ -54,6 +57,28 @@ const validateArguments = ( return connectionId; }); +// Helper function for validateArguments without Effect.ts +const validateArgumentsWithoutEffect = ( + params: MainWorkflowParams, + serviceAccount: { project_id: string }, +): string => { + console.log('[MAIN_WORKFLOW] Validating arguments'); + const regex = new RegExp( + `projects/${serviceAccount.project_id}/subscriptions/notifications__([a-z0-9-]+)`, + ); + const match = params.subscriptionName.toString().match(regex); + if (!match) { + console.log('[MAIN_WORKFLOW] Invalid subscription name:', params.subscriptionName); + throw { + _tag: 'InvalidSubscriptionName' as const, + subscriptionName: params.subscriptionName, + }; + } + const [, connectionId] = match; + console.log('[MAIN_WORKFLOW] Extracted connectionId:', connectionId); + return connectionId; +}; + // Helper function for generating prompt names export const getPromptName = (connectionId: string, prompt: EPrompts) => { return `${connectionId}-${prompt}`; @@ -194,7 +219,7 @@ export class WorkflowRunner extends DurableObject { ); } - private runZeroWorkflow(params: ZeroWorkflowParams) { + public runZeroWorkflow(params: ZeroWorkflowParams) { return Effect.gen(this, function* () { yield* Console.log('[ZERO_WORKFLOW] Starting workflow with payload:', params); const { connectionId, historyId, nextHistoryId } = params; @@ -324,6 +349,8 @@ export class WorkflowRunner extends DurableObject { history.forEach((historyItem) => { // Extract thread IDs from messages historyItem.messagesAdded?.forEach((msg) => { + if (msg.message?.labelIds?.includes('DRAFT')) return; + if (msg.message?.labelIds?.includes('SPAM')) return; if (msg.message?.threadId) { threadsAdded.add(msg.message.threadId); } @@ -529,7 +556,7 @@ export class WorkflowRunner extends DurableObject { ); } - private runThreadWorkflow(params: ThreadWorkflowParams) { + public runThreadWorkflow(params: ThreadWorkflowParams) { return Effect.gen(this, function* () { yield* Console.log('[THREAD_WORKFLOW] Starting workflow with payload:', params); const { connectionId, threadId, providerId } = params; @@ -589,13 +616,11 @@ export class WorkflowRunner extends DurableObject { const workflowEngine = createDefaultWorkflows(); // Create workflow context - const workflowContext = { + const workflowContext: WorkflowContext = { connectionId: connectionId.toString(), threadId: threadId.toString(), thread, foundConnection, - agent, - env: this.env, results: new Map(), }; @@ -709,4 +734,533 @@ export class WorkflowRunner extends DurableObject { Effect.provide(loggerLayer), ); } + + /** Testing workflows without Effect */ + public runThreadWorkflowWithoutEffect(params: ThreadWorkflowParams): Promise { + return this.runThreadWorkflowWithoutEffectImpl(params); + } + + private async runThreadWorkflowWithoutEffectImpl(params: ThreadWorkflowParams): Promise { + try { + console.log('[THREAD_WORKFLOW] Starting workflow with payload:', params); + const { connectionId, threadId, providerId } = params; + const keysToDelete: string[] = []; + + if (providerId === EProviders.google) { + console.log('[THREAD_WORKFLOW] Processing Google provider workflow'); + const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); + + let foundConnection; + try { + console.log('[THREAD_WORKFLOW] Finding connection:', connectionId); + const [connectionRecord] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId.toString())); + + if (!connectionRecord) { + throw new Error(`Connection not found ${connectionId}`); + } + if (!connectionRecord.accessToken || !connectionRecord.refreshToken) { + throw new Error(`Connection is not authorized ${connectionId}`); + } + console.log('[THREAD_WORKFLOW] Found connection:', connectionRecord.id); + foundConnection = connectionRecord; + } catch (error) { + console.error('[THREAD_WORKFLOW] Database error:', error); + throw { _tag: 'DatabaseError' as const, error }; + } finally { + try { + await conn.end(); + } catch (error) { + console.error('[THREAD_WORKFLOW] Failed to close connection:', error); + } + } + + let agent; + try { + agent = await getZeroAgent(foundConnection.id); + } catch (error) { + console.error('[THREAD_WORKFLOW] Failed to get agent:', error); + throw { _tag: 'DatabaseError' as const, error }; + } + + let thread; + try { + console.log('[THREAD_WORKFLOW] Getting thread:', threadId); + thread = await agent.getThread(threadId.toString()); + console.log('[THREAD_WORKFLOW] Found thread with messages:', thread.messages.length); + } catch (error) { + console.error('[THREAD_WORKFLOW] Gmail API error:', error); + throw { _tag: 'GmailApiError' as const, error }; + } + + if (!thread.messages || thread.messages.length === 0) { + console.log('[THREAD_WORKFLOW] Thread has no messages, skipping processing'); + keysToDelete.push(threadId.toString()); + return 'Thread has no messages'; + } + + const workflowEngine = createDefaultWorkflows(); + + const workflowContext: WorkflowContext = { + connectionId: connectionId.toString(), + threadId: threadId.toString(), + thread, + foundConnection, + results: new Map(), + }; + + let workflowResults; + try { + const allResults = new Map(); + const allErrors = new Map(); + + const workflowNames = workflowEngine.getWorkflowNames(); + + for (const workflowName of workflowNames) { + console.log(`[THREAD_WORKFLOW] Executing workflow: ${workflowName}`); + + try { + const { results, errors } = await workflowEngine.executeWorkflow( + workflowName, + workflowContext, + ); + + results.forEach((value, key) => allResults.set(key, value)); + errors.forEach((value, key) => allErrors.set(key, value)); + + console.log(`[THREAD_WORKFLOW] Completed workflow: ${workflowName}`); + } catch (error) { + console.error(`[THREAD_WORKFLOW] Failed to execute workflow ${workflowName}:`, error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + allErrors.set(workflowName, errorObj); + } + } + + workflowResults = { results: allResults, errors: allErrors }; + } catch (error) { + console.error('[THREAD_WORKFLOW] Workflow creation failed:', error); + throw { _tag: 'WorkflowCreationFailed' as const, error }; + } + + workflowEngine.clearContext(workflowContext); + + const successfulSteps = Array.from(workflowResults.results.keys()); + const failedSteps = Array.from(workflowResults.errors.keys()); + + if (successfulSteps.length > 0) { + console.log('[THREAD_WORKFLOW] Successfully executed steps:', successfulSteps); + } + + if (failedSteps.length > 0) { + console.log('[THREAD_WORKFLOW] Failed steps:', failedSteps); + workflowResults.errors.forEach((error, stepId) => { + console.log(`[THREAD_WORKFLOW] Error in step ${stepId}:`, error.message); + }); + } + + keysToDelete.push(threadId.toString()); + + if (keysToDelete.length > 0) { + try { + console.log('[THREAD_WORKFLOW] Bulk deleting keys:', keysToDelete); + const result = await bulkDeleteKeys(keysToDelete); + console.log('[THREAD_WORKFLOW] Bulk delete result:', result); + } catch (error) { + console.error('[THREAD_WORKFLOW] Failed to bulk delete keys:', error); + } + } + + console.log('[THREAD_WORKFLOW] Thread processing complete'); + return 'Thread workflow completed successfully'; + } else { + console.log('[THREAD_WORKFLOW] Unsupported provider:', providerId); + throw { _tag: 'UnsupportedProvider' as const, providerId }; + } + } catch (error) { + console.error('[THREAD_WORKFLOW] Error in workflow:', error); + + try { + console.log( + '[THREAD_WORKFLOW] Clearing processing flag for thread after error:', + params.threadId, + ); + const result = await bulkDeleteKeys([params.threadId.toString()]); + console.log('[THREAD_WORKFLOW] Error cleanup result:', result); + } catch (cleanupError) { + console.error('[THREAD_WORKFLOW] Failed to cleanup thread processing flag:', cleanupError); + } + + throw error; + } + } + + public runMainWorkflowWithoutEffect(params: MainWorkflowParams): Promise { + return this.runMainWorkflowWithoutEffectImpl(params); + } + + private async runMainWorkflowWithoutEffectImpl(params: MainWorkflowParams): Promise { + try { + console.log('[MAIN_WORKFLOW] Starting workflow with payload:', params); + + const { providerId, historyId } = params; + + const serviceAccount = getServiceAccount(); + + let connectionId; + try { + connectionId = validateArgumentsWithoutEffect(params, serviceAccount); + } catch (error) { + console.error('[MAIN_WORKFLOW] Validation error:', error); + throw error; + } + + if (!isValidUUID(connectionId)) { + console.log('[MAIN_WORKFLOW] Invalid connection id format:', connectionId); + throw { + _tag: 'InvalidConnectionId' as const, + connectionId, + }; + } + + let previousHistoryId; + try { + previousHistoryId = await this.env.gmail_history_id.get(connectionId); + } catch (error) { + console.error('[MAIN_WORKFLOW] Failed to get history ID:', error); + previousHistoryId = null; + } + + if (providerId === EProviders.google) { + console.log('[MAIN_WORKFLOW] Processing Google provider workflow'); + console.log('[MAIN_WORKFLOW] Previous history ID:', previousHistoryId); + + const zeroWorkflowParams = { + connectionId, + historyId: previousHistoryId || historyId, + nextHistoryId: historyId, + }; + + let result; + try { + result = await this.runZeroWorkflowWithoutEffect(zeroWorkflowParams); + } catch (error) { + console.error('[MAIN_WORKFLOW] Failed to run zero workflow:', error); + throw { _tag: 'WorkflowCreationFailed' as const, error }; + } + + console.log('[MAIN_WORKFLOW] Zero workflow result:', result); + } else { + console.log('[MAIN_WORKFLOW] Unsupported provider:', providerId); + throw { + _tag: 'UnsupportedProvider' as const, + providerId, + }; + } + + console.log('[MAIN_WORKFLOW] Workflow completed successfully'); + return 'Workflow completed successfully'; + } catch (error) { + console.error('[MAIN_WORKFLOW] Error in workflow:', error); + throw error; + } + } + + public runZeroWorkflowWithoutEffect(params: ZeroWorkflowParams): Promise { + return this.runZeroWorkflowWithoutEffectImpl(params); + } + + private async runZeroWorkflowWithoutEffectImpl(params: ZeroWorkflowParams): Promise { + try { + console.log('[ZERO_WORKFLOW] Starting workflow with payload:', params); + const { connectionId, historyId, nextHistoryId } = params; + + const historyProcessingKey = `history_${connectionId}__${historyId}`; + const keysToDelete: string[] = []; + + let lockAcquired; + try { + const response = await this.env.gmail_processing_threads.put(historyProcessingKey, 'true', { + expirationTtl: 3600, + }); + lockAcquired = response !== null; + } catch (error) { + console.error('[ZERO_WORKFLOW] Failed to acquire lock:', error); + throw { _tag: 'WorkflowCreationFailed' as const, error }; + } + + if (!lockAcquired) { + console.log('[ZERO_WORKFLOW] History already being processed:', { + connectionId, + historyId, + }); + throw { + _tag: 'HistoryAlreadyProcessing' as const, + connectionId, + historyId, + }; + } + + console.log('[ZERO_WORKFLOW] Acquired processing lock for history:', historyProcessingKey); + + const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); + + let foundConnection; + try { + console.log('[ZERO_WORKFLOW] Finding connection:', connectionId); + const [connectionRecord] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId.toString())); + + if (!connectionRecord) { + throw new Error(`Connection not found ${connectionId}`); + } + if (!connectionRecord.accessToken || !connectionRecord.refreshToken) { + throw new Error(`Connection is not authorized ${connectionId}`); + } + console.log('[ZERO_WORKFLOW] Found connection:', connectionRecord.id); + foundConnection = connectionRecord; + } catch (error) { + console.error('[ZERO_WORKFLOW] Database error:', error); + throw { _tag: 'DatabaseError' as const, error }; + } finally { + try { + await conn.end(); + } catch (error) { + console.error('[ZERO_WORKFLOW] Failed to close connection:', error); + } + } + + let agent; + try { + agent = await getZeroAgent(foundConnection.id); + } catch (error) { + console.error('[ZERO_WORKFLOW] Failed to get agent:', error); + throw { _tag: 'DatabaseError' as const, error }; + } + + if (foundConnection.providerId === EProviders.google) { + console.log('[ZERO_WORKFLOW] Processing Google provider workflow'); + + let history; + try { + console.log('[ZERO_WORKFLOW] Getting Gmail history with ID:', historyId); + const { history: historyData } = (await agent.listHistory(historyId.toString())) as { + history: gmail_v1.Schema$History[]; + }; + console.log( + '[ZERO_WORKFLOW] Found history entries:', + JSON.stringify(historyData, null, 2), + ); + history = historyData; + } catch (error) { + console.error('[ZERO_WORKFLOW] Gmail API error:', error); + throw { _tag: 'GmailApiError' as const, error }; + } + + try { + console.log('[ZERO_WORKFLOW] Updating next history ID:', nextHistoryId); + await this.env.gmail_history_id.put(connectionId.toString(), nextHistoryId.toString()); + } catch (error) { + console.error('[ZERO_WORKFLOW] Failed to update history ID:', error); + throw { _tag: 'WorkflowCreationFailed' as const, error }; + } + + if (!history.length) { + console.log('[ZERO_WORKFLOW] No history found, skipping'); + keysToDelete.push(historyProcessingKey); + return 'No history found'; + } + + const threadsAdded = new Set(); + const threadLabelChanges = new Map< + string, + { addLabels: Set; removeLabels: Set } + >(); + + const processLabelChange = ( + labelChange: { message?: gmail_v1.Schema$Message; labelIds?: string[] | null }, + isAddition: boolean, + ) => { + const threadId = labelChange.message?.threadId; + if (!threadId || !labelChange.labelIds?.length) return; + + let changes = threadLabelChanges.get(threadId); + if (!changes) { + changes = { addLabels: new Set(), removeLabels: new Set() }; + threadLabelChanges.set(threadId, changes); + } + + const targetSet = isAddition ? changes.addLabels : changes.removeLabels; + labelChange.labelIds.forEach((labelId) => targetSet.add(labelId)); + }; + + history.forEach((historyItem) => { + historyItem.messagesAdded?.forEach((msg) => { + if (msg.message?.labelIds?.includes('DRAFT')) return; + if (msg.message?.labelIds?.includes('SPAM')) return; + if (msg.message?.threadId) { + threadsAdded.add(msg.message.threadId); + } + }); + + historyItem.labelsAdded?.forEach((labelAdded) => processLabelChange(labelAdded, true)); + historyItem.labelsRemoved?.forEach((labelRemoved) => + processLabelChange(labelRemoved, false), + ); + }); + + console.log( + '[ZERO_WORKFLOW] Found unique thread IDs:', + Array.from(threadLabelChanges.keys()), + Array.from(threadsAdded), + ); + + if (threadsAdded.size > 0) { + const threadWorkflowParams = Array.from(threadsAdded); + + const syncResults: Array<{ threadId: string; result: any }> = []; + const syncErrors: Array<{ threadId: string; error: Error }> = []; + + for (const threadId of threadWorkflowParams) { + try { + const result = await agent.syncThread({ threadId }); + console.log(`[ZERO_WORKFLOW] Successfully synced thread ${threadId}`); + syncResults.push({ threadId, result }); + } catch (error) { + console.error(`[ZERO_WORKFLOW] Failed to sync thread ${threadId}:`, error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + syncErrors.push({ threadId, error: errorObj }); + } + } + + const syncedCount = syncResults.length; + const failedCount = threadWorkflowParams.length - syncedCount; + + if (failedCount > 0) { + console.log( + `[ZERO_WORKFLOW] Warning: ${failedCount}/${threadWorkflowParams.length} thread syncs failed. Successfully synced: ${syncedCount}`, + ); + } else { + console.log(`[ZERO_WORKFLOW] Successfully synced all ${syncedCount} threads`); + } + + console.log('[ZERO_WORKFLOW] Synced threads:', syncResults); + + if (syncedCount > 0) { + try { + await agent.reloadFolder('inbox'); + console.log('[ZERO_WORKFLOW] Successfully reloaded inbox folder'); + } catch (error) { + console.log('[ZERO_WORKFLOW] Failed to reload inbox folder'); + } + + console.log( + `[ZERO_WORKFLOW] Running thread workflows for ${syncedCount} synced threads`, + ); + + const threadWorkflowResults: Array<{ threadId: string; result: string }> = []; + const threadWorkflowErrors: Array<{ threadId: string; error: Error }> = []; + + for (const { threadId } of syncResults) { + try { + const result = await this.runThreadWorkflowWithoutEffect({ + connectionId, + threadId, + providerId: foundConnection.providerId, + }); + console.log(`[ZERO_WORKFLOW] Successfully ran thread workflow for ${threadId}`); + threadWorkflowResults.push({ threadId, result }); + } catch (error) { + console.log( + `[ZERO_WORKFLOW] Failed to run thread workflow for ${threadId}:`, + error, + ); + const errorObj = error instanceof Error ? error : new Error(String(error)); + threadWorkflowErrors.push({ threadId, error: errorObj }); + } + } + + const threadWorkflowSuccessCount = threadWorkflowResults.length; + const threadWorkflowFailedCount = syncedCount - threadWorkflowSuccessCount; + + if (threadWorkflowFailedCount > 0) { + console.log( + `[ZERO_WORKFLOW] Warning: ${threadWorkflowFailedCount}/${syncedCount} thread workflows failed. Successfully processed: ${threadWorkflowSuccessCount}`, + ); + } else { + console.log( + `[ZERO_WORKFLOW] Successfully ran all ${threadWorkflowSuccessCount} thread workflows`, + ); + } + } + } + + if (threadLabelChanges.size > 0) { + console.log( + `[ZERO_WORKFLOW] Processing label changes for ${threadLabelChanges.size} threads`, + ); + + for (const [threadId, changes] of threadLabelChanges) { + const addLabels = Array.from(changes.addLabels); + const removeLabels = Array.from(changes.removeLabels); + + if (addLabels.length > 0 || removeLabels.length > 0) { + console.log( + `[ZERO_WORKFLOW] Modifying labels for thread ${threadId}: +${addLabels.length} -${removeLabels.length}`, + ); + try { + await agent.modifyThreadLabelsInDB(threadId, addLabels, removeLabels); + } catch (error) { + console.log(`[ZERO_WORKFLOW] Failed to modify labels for thread ${threadId}`); + } + } + } + + console.log('[ZERO_WORKFLOW] Completed label modifications'); + } else { + console.log('[ZERO_WORKFLOW] No threads with label changes to process'); + } + + keysToDelete.push(historyProcessingKey); + + if (keysToDelete.length > 0) { + try { + console.log('[ZERO_WORKFLOW] Bulk deleting keys:', keysToDelete); + const result = await bulkDeleteKeys(keysToDelete); + console.log('[ZERO_WORKFLOW] Bulk delete result:', result); + } catch (error) { + console.error('[ZERO_WORKFLOW] Failed to bulk delete keys:', error); + } + } + + console.log('[ZERO_WORKFLOW] Processing complete'); + return 'Zero workflow completed successfully'; + } else { + console.log('[ZERO_WORKFLOW] Unsupported provider:', foundConnection.providerId); + throw { + _tag: 'UnsupportedProvider' as const, + providerId: foundConnection.providerId, + }; + } + } catch (error) { + console.error('[ZERO_WORKFLOW] Error in workflow:', error); + + try { + const errorCleanupKey = `history_${params.connectionId}__${params.historyId}`; + console.log( + '[ZERO_WORKFLOW] Clearing processing flag for history after error:', + errorCleanupKey, + ); + const result = await bulkDeleteKeys([errorCleanupKey]); + console.log('[ZERO_WORKFLOW] Error cleanup result:', result); + } catch (cleanupError) { + console.error('[ZERO_WORKFLOW] Failed to cleanup processing flag:', cleanupError); + } + + throw error; + } + } } diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 5e61bd59ce..4967b2bb90 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -609,9 +609,12 @@ export class ZeroDriver extends AIChatAgent { }); if (_connection) this.driver = connectionToDriver(_connection); this.ctx.waitUntil(conn.end()); - this.ctx.waitUntil(this.syncThreads('inbox')); - this.ctx.waitUntil(this.syncThreads('sent')); - this.ctx.waitUntil(this.syncThreads('spam')); + const threadCount = await this.getThreadCount(); + if (threadCount < maxCount) { + this.ctx.waitUntil(this.syncThreads('inbox')); + this.ctx.waitUntil(this.syncThreads('sent')); + this.ctx.waitUntil(this.syncThreads('spam')); + } } } async rawListThreads(params: { @@ -627,11 +630,11 @@ export class ZeroDriver extends AIChatAgent { return await this.driver.list(params); } - async getThread(threadId: string) { + async getThread(threadId: string, includeDrafts: boolean = false) { if (!this.driver) { throw new Error('No driver available'); } - return await this.getThreadFromDB(threadId); + return await this.getThreadFromDB(threadId, includeDrafts); } // async markThreadsRead(threadIds: string[]) { @@ -1630,7 +1633,7 @@ export class ZeroDriver extends AIChatAgent { } } - async getThreadFromDB(id: string): Promise { + async getThreadFromDB(id: string, includeDrafts: boolean = false): Promise { try { const result = this.sql` SELECT @@ -1661,10 +1664,16 @@ export class ZeroDriver extends AIChatAgent { const row = result[0] as { latest_label_ids: string }; const storedThread = await env.THREADS_BUCKET.get(this.getThreadKey(id)); - const messages: ParsedMessage[] = storedThread + let messages: ParsedMessage[] = storedThread ? (JSON.parse(await storedThread.text()) as IGetThreadResponse).messages : []; + const isLatestDraft = messages.some((e) => e.isDraft === true); + + if (!includeDrafts) { + messages = messages.filter((e) => e.isDraft !== true); + } + const latestLabelIds = JSON.parse(row.latest_label_ids || '[]'); return { @@ -1673,6 +1682,7 @@ export class ZeroDriver extends AIChatAgent { hasUnread: latestLabelIds.includes('UNREAD'), totalReplies: messages.filter((e) => e.isDraft !== true).length, labels: latestLabelIds.map((id: string) => ({ id, name: id })), + isLatestDraft, } satisfies IGetThreadResponse; } catch (error) { console.error('Failed to get thread from database:', error); @@ -1726,12 +1736,12 @@ export class ZeroDriver extends AIChatAgent { return await this.getThreadsFromDB(params); } - async get(id: string) { - if (!this.driver) { - throw new Error('No driver available'); - } - return await this.getThreadFromDB(id); - } + // async get(id: string, includeDrafts: boolean = false) { + // if (!this.driver) { + // throw new Error('No driver available'); + // } + // return await this.getThreadFromDB(id, includeDrafts); + // } } export class ZeroAgent extends AIChatAgent { diff --git a/apps/server/src/routes/agent/rpc.ts b/apps/server/src/routes/agent/rpc.ts index 5efbc2ace6..93c8665cd9 100644 --- a/apps/server/src/routes/agent/rpc.ts +++ b/apps/server/src/routes/agent/rpc.ts @@ -86,8 +86,8 @@ export class DriverRpcDO extends RpcTarget { return await this.mainDo.list(params); } - async getThread(threadId: string) { - return await this.mainDo.get(threadId); + async getThread(threadId: string, includeDrafts: boolean = false) { + return await this.mainDo.getThread(threadId, includeDrafts); } async markThreadsRead(threadIds: string[]) { diff --git a/apps/server/src/thread-workflow-utils/index.ts b/apps/server/src/thread-workflow-utils/index.ts index 75bed52996..a7658a028d 100644 --- a/apps/server/src/thread-workflow-utils/index.ts +++ b/apps/server/src/thread-workflow-utils/index.ts @@ -1,6 +1,5 @@ import type { IGetThreadResponse } from '../lib/driver/types'; import { composeEmail } from '../trpc/routes/ai/compose'; -import { getZeroAgent } from '../lib/server-utils'; import { type ParsedMessage } from '../types'; import { connection } from '../db/schema'; @@ -8,25 +7,34 @@ const shouldGenerateDraft = async ( thread: IGetThreadResponse, foundConnection: typeof connection.$inferSelect, ): Promise => { - if (!thread.messages || thread.messages.length === 0) return false; + if (!thread.messages || thread.messages.length === 0) { + console.log('[SHOULD_GENERATE_DRAFT] No messages in thread'); + return false; + } const latestMessage = thread.messages[thread.messages.length - 1]; if (latestMessage.sender?.email?.toLowerCase() === foundConnection.email?.toLowerCase()) { + console.log('[SHOULD_GENERATE_DRAFT] Latest message is from user, skipping draft'); return false; } + const senderEmail = latestMessage.sender?.email?.toLowerCase() || ''; + const subject = latestMessage.subject?.toLowerCase() || ''; + const decodedBody = latestMessage.decodedBody?.toLowerCase() || ''; + + const automatedEmailRegex = /(no-?reply|donotreply|do-not-reply)/; + const automatedSubjectRegex = /(newsletter|unsubscribe|notification)/; + const automatedBodyRegex = /(do not reply|this is an automated)/; + if ( - latestMessage.sender?.email?.toLowerCase().includes('no-reply') || - latestMessage.sender?.email?.toLowerCase().includes('noreply') || - latestMessage.sender?.email?.toLowerCase().includes('donotreply') || - latestMessage.sender?.email?.toLowerCase().includes('do-not-reply') || - latestMessage.subject?.toLowerCase().includes('newsletter') || - latestMessage.subject?.toLowerCase().includes('unsubscribe') || - latestMessage.subject?.toLowerCase().includes('notification') || - latestMessage.decodedBody?.toLowerCase().includes('do not reply') || - latestMessage.decodedBody?.toLowerCase().includes('this is an automated') + automatedEmailRegex.test(senderEmail) || + automatedSubjectRegex.test(subject) || + automatedBodyRegex.test(decodedBody) ) { + console.log( + '[SHOULD_GENERATE_DRAFT] Message is likely automated or not actionable, skipping draft', + ); return false; } @@ -34,33 +42,29 @@ const shouldGenerateDraft = async ( const messageDate = new Date(latestMessage.receivedOn); const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); if (messageDate < sevenDaysAgo) { + console.log('[SHOULD_GENERATE_DRAFT] Latest message is older than 7 days, skipping draft'); return false; } } try { - const agent = await getZeroAgent(foundConnection.id); - const threadId = thread.messages[0]?.threadId; if (!threadId) { console.log('[SHOULD_GENERATE_DRAFT] No thread ID found, skipping draft check'); return true; } - const draftsResponse: any = await agent.listDrafts({ maxResults: 100 }); - - const hasDraftForThread = draftsResponse.threads.some((draft: any) => { - return draft.id === threadId; - }); + const latestDraft = thread.isLatestDraft; - if (hasDraftForThread) { - console.log(`[SHOULD_GENERATE_DRAFT] Draft already exists for thread ${threadId}, skipping`); + if (latestDraft) { + console.log('[SHOULD_GENERATE_DRAFT] Draft already exists in thread, skipping draft'); return false; } } catch (error) { console.error('[SHOULD_GENERATE_DRAFT] Error checking for existing drafts:', error); } + console.log('[SHOULD_GENERATE_DRAFT] Draft should be generated for this thread'); return true; }; diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts index dd7a639938..d7eb8935d4 100644 --- a/apps/server/src/thread-workflow-utils/workflow-engine.ts +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -61,13 +61,13 @@ export class WorkflowEngine { const shouldExecute = step.condition ? await step.condition({ ...context, results }) : true; if (!shouldExecute) { console.log(`[WORKFLOW_ENGINE] Condition not met for step: ${step.name}`); - continue; + break; } console.log(`[WORKFLOW_ENGINE] Executing step: ${step.name}`); const result = await step.action({ ...context, results }); results.set(step.id, result); - console.log(`[WORKFLOW_ENGINE] Completed step: ${step.name}`); + console.log(`[WORKFLOW_ENGINE] Completed step: ${step.name}`, result); } catch (error) { const errorObj = error instanceof Error ? error : new Error(String(error)); console.error(`[WORKFLOW_ENGINE] Error in step ${step.name}:`, errorObj); @@ -98,31 +98,23 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'auto-draft-generation', description: 'Automatically generates drafts for threads that require responses', steps: [ - { - id: 'check-workflow-execution', - name: 'Check Workflow Execution', - description: 'Checks if this workflow has already been executed for this thread', - enabled: true, - action: workflowFunctions.checkWorkflowExecution, - }, { id: 'check-draft-eligibility', name: 'Check Draft Eligibility', description: 'Determines if a draft should be generated for this thread', enabled: true, + errorHandling: 'fail', condition: async (context) => { - const executionCheck = context.results?.get('check-workflow-execution'); - if (executionCheck?.alreadyExecuted) { - return false; - } - return await shouldGenerateDraft(context.thread, context.foundConnection); - }, - action: async (context) => { - console.log('[WORKFLOW_ENGINE] Thread eligible for draft generation', { + const shouldGenerate = await shouldGenerateDraft(context.thread, context.foundConnection); + console.log('[WORKFLOW_ENGINE] Draft eligibility check', { threadId: context.threadId, connectionId: context.connectionId, + shouldGenerate, }); - return { eligible: true }; + return shouldGenerate; + }, + action: async (context) => { + return context; }, }, { @@ -170,22 +162,11 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'message-vectorization', description: 'Vectorizes thread messages for search and analysis', steps: [ - { - id: 'check-workflow-execution', - name: 'Check Workflow Execution', - description: 'Checks if this workflow has already been executed for this thread', - enabled: true, - action: workflowFunctions.checkWorkflowExecution, - }, { id: 'find-messages-to-vectorize', name: 'Find Messages to Vectorize', description: 'Identifies messages that need vectorization', enabled: true, - condition: async (context) => { - const executionCheck = context.results?.get('check-workflow-execution'); - return !executionCheck?.alreadyExecuted; - }, action: workflowFunctions.findMessagesToVectorize, }, { @@ -218,22 +199,11 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'thread-summary', description: 'Generates and stores thread summaries', steps: [ - { - id: 'check-workflow-execution', - name: 'Check Workflow Execution', - description: 'Checks if this workflow has already been executed for this thread', - enabled: true, - action: workflowFunctions.checkWorkflowExecution, - }, { id: 'check-existing-summary', name: 'Check Existing Summary', description: 'Checks if a thread summary already exists', enabled: true, - condition: async (context) => { - const executionCheck = context.results?.get('check-workflow-execution'); - return !executionCheck?.alreadyExecuted; - }, action: workflowFunctions.checkExistingSummary, }, { @@ -252,14 +222,14 @@ export const createDefaultWorkflows = (): WorkflowEngine => { action: workflowFunctions.upsertThreadSummary, errorHandling: 'continue', }, - { - id: 'cleanup-workflow-execution', - name: 'Cleanup Workflow Execution', - description: 'Removes workflow execution tracking', - enabled: true, - action: workflowFunctions.cleanupWorkflowExecution, - errorHandling: 'continue', - }, + // { + // id: 'cleanup-workflow-execution', + // name: 'Cleanup Workflow Execution', + // description: 'Removes workflow execution tracking', + // enabled: true, + // action: workflowFunctions.cleanupWorkflowExecution, + // errorHandling: 'continue', + // }, ], }; @@ -267,22 +237,11 @@ export const createDefaultWorkflows = (): WorkflowEngine => { name: 'label-generation', description: 'Generates and applies labels to threads', steps: [ - { - id: 'check-workflow-execution', - name: 'Check Workflow Execution', - description: 'Checks if this workflow has already been executed for this thread', - enabled: true, - action: workflowFunctions.checkWorkflowExecution, - }, { id: 'get-user-labels', name: 'Get User Labels', description: 'Retrieves user-defined labels', enabled: true, - condition: async (context) => { - const executionCheck = context.results?.get('check-workflow-execution'); - return !executionCheck?.alreadyExecuted; - }, action: workflowFunctions.getUserLabels, }, { @@ -313,7 +272,7 @@ export const createDefaultWorkflows = (): WorkflowEngine => { }; engine.registerWorkflow(autoDraftWorkflow); - engine.registerWorkflow(vectorizationWorkflow); + // engine.registerWorkflow(vectorizationWorkflow); engine.registerWorkflow(threadSummaryWorkflow); engine.registerWorkflow(labelGenerationWorkflow); diff --git a/apps/server/src/thread-workflow-utils/workflow-functions.ts b/apps/server/src/thread-workflow-utils/workflow-functions.ts index b3d422b58e..c0a155cd0c 100644 --- a/apps/server/src/thread-workflow-utils/workflow-functions.ts +++ b/apps/server/src/thread-workflow-utils/workflow-functions.ts @@ -4,8 +4,8 @@ import { ReSummarizeThread, SummarizeThread, } from '../lib/brain.fallback.prompts'; -import { analyzeEmailIntent, generateAutomaticDraft, shouldGenerateDraft } from './index'; import { EPrompts, defaultLabels, type ParsedMessage } from '../types'; +import { analyzeEmailIntent, generateAutomaticDraft } from './index'; import { getPrompt, getEmbeddingVector } from '../pipelines.effect'; import { messageToXML, threadToXML } from './workflow-utils'; import type { WorkflowContext } from './workflow-engine'; @@ -18,29 +18,6 @@ import { Effect } from 'effect'; export type WorkflowFunction = (context: WorkflowContext) => Promise; export const workflowFunctions: Record = { - shouldGenerateDraft: async (context) => { - return await shouldGenerateDraft(context.thread, context.foundConnection); - }, - - checkWorkflowExecution: async (context) => { - const workflowKey = `workflow_${context.threadId}`; - const lastExecution = await env.gmail_processing_threads.get(workflowKey); - - if (lastExecution) { - console.log('[WORKFLOW_FUNCTIONS] Workflow already executed for thread:', context.threadId); - return { alreadyExecuted: true }; - } - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - await env.gmail_processing_threads.put(workflowKey, Date.now().toString(), { - expirationTtl: 3600, - }); - - console.log('[WORKFLOW_FUNCTIONS] Marked workflow as executed for thread:', context.threadId); - return { alreadyExecuted: false }; - }, - analyzeEmailIntent: async (context) => { if (!context.thread.messages || context.thread.messages.length === 0) { throw new Error('Cannot analyze email intent: No messages in thread'); @@ -62,6 +39,7 @@ export const workflowFunctions: Record = { validateResponseNeeded: async (context) => { const intentResult = context.results?.get('analyze-email-intent'); if (!intentResult) { + console.log('[WORKFLOW_FUNCTIONS] Email intent analysis not available'); throw new Error('Email intent analysis not available'); } @@ -78,6 +56,8 @@ export const workflowFunctions: Record = { return { requiresResponse: false }; } + console.log('[WORKFLOW_FUNCTIONS] Email requires a response, continuing with draft generation'); + return { requiresResponse: true }; }, @@ -402,6 +382,7 @@ export const workflowFunctions: Record = { getUserLabels: async (context) => { try { + console.log('[WORKFLOW_FUNCTIONS] Getting user labels for connection:', context.results); const agent = await getZeroAgent(context.connectionId); const userAccountLabels = await agent.getUserLabels(); return { userAccountLabels }; @@ -413,6 +394,7 @@ export const workflowFunctions: Record = { generateLabels: async (context) => { const summaryResult = context.results?.get('generate-thread-summary'); + console.log(summaryResult, context.results); if (!summaryResult?.summary) { console.log('[WORKFLOW_FUNCTIONS] No summary available for label generation'); return { labels: [] }; diff --git a/apps/server/src/trpc/routes/mail.ts b/apps/server/src/trpc/routes/mail.ts index f5ec9d4828..cd35d92c60 100644 --- a/apps/server/src/trpc/routes/mail.ts +++ b/apps/server/src/trpc/routes/mail.ts @@ -40,7 +40,7 @@ export const mailRouter = router({ .query(async ({ input, ctx }) => { const { activeConnection } = ctx; const agent = await getZeroAgent(activeConnection.id); - return await agent.getThread(input.id); + return await agent.getThread(input.id, true); }), count: activeDriverProcedure .output( diff --git a/apps/server/wrangler.jsonc b/apps/server/wrangler.jsonc index 879d87e370..8f9065e397 100644 --- a/apps/server/wrangler.jsonc +++ b/apps/server/wrangler.jsonc @@ -118,7 +118,7 @@ }, ], "vars": { - "NODE_ENV": "development", + "NODE_ENV": "local", "COOKIE_DOMAIN": "localhost", "VITE_PUBLIC_BACKEND_URL": "http://localhost:8787", "VITE_PUBLIC_APP_URL": "http://localhost:3000", @@ -130,9 +130,11 @@ "DROP_AGENT_TABLES": "false", "THREAD_SYNC_MAX_COUNT": "5", "THREAD_SYNC_LOOP": "false", - "DISABLE_WORKFLOWS": "false", + "DISABLE_WORKFLOWS": "true", "AUTORAG_ID": "", "USE_OPENAI": "true", + "CLOUDFLARE_ACCOUNT_ID": "397b3b4fac213b9b382d0f1fafdbb215", + "CLOUDFLARE_API_TOKEN": "wbrJ9McsQhjCxv1pzxLLK8keT-0tM1ab-QbmESg6", }, "kv_namespaces": [ { @@ -292,7 +294,7 @@ "DROP_AGENT_TABLES": "false", "THREAD_SYNC_MAX_COUNT": "20", "THREAD_SYNC_LOOP": "true", - "DISABLE_WORKFLOWS": "false", + "DISABLE_WORKFLOWS": "true", }, "kv_namespaces": [ { From f664c537b293e18f5e33e650eede06ffa81d3b7b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:18:18 -0700 Subject: [PATCH 24/48] feat: sync threadId to ZeroAgent via WebSocket for multi-tab support (#1870) # feat: sync threadId to ZeroAgent via WebSocket for multi-tab support ## Summary This PR implements real-time threadId synchronization between the frontend and ZeroAgent backend to provide better context to the AI assistant. When users navigate between email threads in the frontend (via `useQueryState('threadId')`), the system now automatically sends WebSocket updates to the ZeroAgent so the AI knows which thread the user is currently viewing. **Key changes:** - Added `ThreadIdUpdate` WebSocket message type for bidirectional communication - Created `ThreadIdSyncProvider` component that monitors threadId changes and sends updates - Modified `ZeroAgent` to store threadId per connection for multi-tab support - Updated AI system prompt generation to include current threadId context - Maintains connection isolation so multiple tabs can have different active threads ## Review & Testing Checklist for Human - [ ] **Verify frontend loads properly and threadId sync works** - There was a CSS import error during development that prevented browser testing. Confirm the app loads and threadId changes trigger WebSocket messages in DevTools. - [ ] **Test multi-tab support** - Open multiple browser tabs, navigate to different threads in each tab, and verify the AI chat in each tab has context for the correct thread. - [ ] **Confirm AI system prompt includes threadId** - Start a chat conversation and verify the AI assistant has proper context about the current thread the user is viewing. - [ ] **Check for regressions** - Verify existing email navigation, AI chat functionality, and WebSocket notifications still work as expected. - [ ] **Test edge cases** - Try rapid thread navigation, WebSocket reconnections, and tab closing/opening to ensure the system handles these scenarios gracefully. --- ### Diagram ```mermaid %%{ init : { "theme" : "default" }}%% graph TD Frontend["apps/mail/components/party.tsx
NotificationProvider"]:::major-edit ThreadSync["ThreadIdSyncProvider
(new component)"]:::major-edit QueryState["useQueryState('threadId')
(existing hook)"]:::context Backend["apps/server/src/routes/agent/index.ts
ZeroAgent class"]:::major-edit Types["apps/server/src/routes/agent/types.ts
Message Types"]:::minor-edit Prompt["apps/server/src/lib/prompts.ts
AiChatPrompt"]:::context QueryState --> ThreadSync ThreadSync --> Frontend Frontend -->|"WebSocket ThreadIdUpdate"| Backend Backend --> Prompt Types -.->|"defines message structure"| Frontend Types -.->|"defines message structure"| Backend subgraph Legend L1[Major Edit]:::major-edit L2[Minor Edit]:::minor-edit L3[Context/No Edit]:::context end classDef major-edit fill:#90EE90 classDef minor-edit fill:#87CEEB classDef context fill:#FFFFFF ``` ### Notes - **Session Info**: Requested by @MrgSub, implemented in Devin session: https://app.devin.ai/sessions/f2c92b4c778d4314a4c9947ca2022667 - **Environment Issue**: Frontend had CSS import errors during development preventing full browser testing. The implementation follows existing WebSocket patterns but needs verification in a working environment. - **Architecture**: Uses existing connection management system where each browser tab gets a unique connectionId, enabling independent threadId tracking per tab. - **Backward Compatibility**: Changes are additive and shouldn't break existing functionality, but the system prompt generation flow has been modified to use stored threadId instead of request-time threadId. --- ## Summary by cubic Enabled real-time syncing of the current threadId to ZeroAgent over WebSocket, allowing each browser tab to maintain its own thread context for multi-tab support. - **New Features** - Added a ThreadIdSyncProvider component to detect thread changes and send updates to ZeroAgent. - ZeroAgent now tracks threadId per connection, so each tab can have independent context. - System prompt generation now uses the correct threadId for each connection. --- apps/mail/components/party.tsx | 17 ++++++++++++++++- apps/server/src/pipelines.ts | 4 ++-- apps/server/src/routes/agent/index.ts | 16 +++++++++++++--- apps/server/src/routes/agent/types.ts | 5 +++++ .../thread-workflow-utils/workflow-engine.ts | 2 +- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/mail/components/party.tsx b/apps/mail/components/party.tsx index d53b1211b2..a5fe1cccad 100644 --- a/apps/mail/components/party.tsx +++ b/apps/mail/components/party.tsx @@ -4,6 +4,8 @@ import useSearchLabels from '@/hooks/use-labels-search'; import { useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/providers/query-provider'; import { usePartySocket } from 'partysocket/react'; +import { useQueryState } from 'nuqs'; +import { useEffect, useRef } from 'react'; // 10 seconds is appropriate for real-time notifications @@ -23,6 +25,7 @@ export enum OutgoingMessageType { ChatClear = 'cf_agent_chat_clear', Mail_List = 'zero_mail_list_threads', Mail_Get = 'zero_mail_get_thread', + ThreadIdUpdate = 'zero_thread_id_update', } export const NotificationProvider = () => { @@ -31,8 +34,10 @@ export const NotificationProvider = () => { const { data: activeConnection } = useActiveConnection(); const [searchValue] = useSearchValue(); const { labels } = useSearchLabels(); + const [threadId] = useQueryState('threadId'); + const prevThreadIdRef = useRef(null); - usePartySocket({ + const socket = usePartySocket({ party: 'zero-agent', room: activeConnection?.id ? String(activeConnection.id) : 'general', prefix: 'agents', @@ -66,5 +71,15 @@ export const NotificationProvider = () => { }, }); + useEffect(() => { + if (socket && prevThreadIdRef.current !== threadId) { + prevThreadIdRef.current = threadId; + socket.send(JSON.stringify({ + type: OutgoingMessageType.ThreadIdUpdate, + threadId: threadId || null, + })); + } + }, [threadId, socket]); + return <>; }; diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index 355c428cc2..efd118edce 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -1153,7 +1153,7 @@ export class WorkflowRunner extends DurableObject { try { await agent.reloadFolder('inbox'); console.log('[ZERO_WORKFLOW] Successfully reloaded inbox folder'); - } catch (error) { + } catch { console.log('[ZERO_WORKFLOW] Failed to reload inbox folder'); } @@ -1213,7 +1213,7 @@ export class WorkflowRunner extends DurableObject { ); try { await agent.modifyThreadLabelsInDB(threadId, addLabels, removeLabels); - } catch (error) { + } catch { console.log(`[ZERO_WORKFLOW] Failed to modify labels for thread ${threadId}`); } } diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 4967b2bb90..d8118be985 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -1746,6 +1746,7 @@ export class ZeroDriver extends AIChatAgent { export class ZeroAgent extends AIChatAgent { private chatMessageAbortControllers: Map = new Map(); + private connectionThreadIds: Map = new Map(); async registerZeroMCP() { await this.mcp.connect(env.VITE_PUBLIC_BACKEND_URL + '/sse', { @@ -1777,10 +1778,13 @@ export class ZeroAgent extends AIChatAgent { private getDataStreamResponse( onFinish: StreamTextOnFinishCallback<{}>, - _?: { + options?: { abortSignal: AbortSignal | undefined; + connectionId?: string; }, ) { + const connectionId = options?.connectionId || 'general'; + const currentThreadId = this.connectionThreadIds.get(connectionId) || ''; const dataStreamResponse = createDataStreamResponse({ execute: async (dataStream) => { if (this.name === 'general') return; @@ -1818,7 +1822,7 @@ export class ZeroAgent extends AIChatAgent { onError: (error) => { console.error('Error in streamText', error); }, - system: await getPrompt(getPromptName(connectionId, EPrompts.Chat), AiChatPrompt('')), + system: await getPrompt(getPromptName(connectionId, EPrompts.Chat), AiChatPrompt(currentThreadId)), }); result.mergeIntoDataStream(dataStream); @@ -1908,7 +1912,7 @@ export class ZeroAgent extends AIChatAgent { await this.persistMessages(finalMessages, [connection.id]); this.removeAbortController(chatMessageId); }, - abortSignal ? { abortSignal } : undefined, + abortSignal ? { abortSignal, connectionId: connection.id } : { connectionId: connection.id }, ); if (response) { @@ -1949,6 +1953,11 @@ export class ZeroAgent extends AIChatAgent { this.cancelChatRequest(data.id); break; } + case IncomingMessageType.ThreadIdUpdate: { + this.connectionThreadIds.set(connection.id, data.threadId); + console.log(`[ZeroAgent] Updated threadId for connection ${connection.id}: ${data.threadId}`); + break; + } // case IncomingMessageType.Mail_List: { // const result = await this.getThreadsFromDB({ // labelIds: data.labelIds, @@ -2015,6 +2024,7 @@ export class ZeroAgent extends AIChatAgent { onFinish: StreamTextOnFinishCallback<{}>, options?: { abortSignal: AbortSignal | undefined; + connectionId?: string; }, ) { return this.getDataStreamResponse(onFinish, options); diff --git a/apps/server/src/routes/agent/types.ts b/apps/server/src/routes/agent/types.ts index ce3dd33be6..d9990fea7e 100644 --- a/apps/server/src/routes/agent/types.ts +++ b/apps/server/src/routes/agent/types.ts @@ -7,6 +7,7 @@ export enum IncomingMessageType { ChatRequestCancel = 'cf_agent_chat_request_cancel', Mail_List = 'zero_mail_list_threads', Mail_Get = 'zero_mail_get_thread', + ThreadIdUpdate = 'zero_thread_id_update', } export enum OutgoingMessageType { @@ -46,6 +47,10 @@ export type IncomingMessage = | { type: IncomingMessageType.Mail_Get; threadId: string; + } + | { + type: IncomingMessageType.ThreadIdUpdate; + threadId: string | null; }; export type OutgoingMessage = diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts index d7eb8935d4..05709b1908 100644 --- a/apps/server/src/thread-workflow-utils/workflow-engine.ts +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -158,7 +158,7 @@ export const createDefaultWorkflows = (): WorkflowEngine => { ], }; - const vectorizationWorkflow: WorkflowDefinition = { + const _vectorizationWorkflow: WorkflowDefinition = { name: 'message-vectorization', description: 'Vectorizes thread messages for search and analysis', steps: [ From 9707613cf4a29238e98c297851e0f2c6a9d8ee64 Mon Sep 17 00:00:00 2001 From: amrit Date: Thu, 31 Jul 2025 01:10:16 +0530 Subject: [PATCH 25/48] feat: add email template management functionality (#1573) --- .../mail/components/create/email-composer.tsx | 10 + .../components/create/template-button.tsx | 260 ++++ apps/mail/components/ui/toast.tsx | 2 +- apps/mail/hooks/use-templates.ts | 11 + .../src/db/migrations/0035_giant_hydra.sql | 14 + .../db/migrations/0036_petite_mole_man.sql | 2 + .../src/db/migrations/meta/0036_snapshot.json | 1228 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 14 + apps/server/src/db/schema.ts | 22 + apps/server/src/lib/templates-manager.ts | 89 ++ apps/server/src/main.ts | 58 + apps/server/src/trpc/index.ts | 4 +- apps/server/src/trpc/routes/templates.ts | 36 + 13 files changed, 1748 insertions(+), 2 deletions(-) create mode 100644 apps/mail/components/create/template-button.tsx create mode 100644 apps/mail/hooks/use-templates.ts create mode 100644 apps/server/src/db/migrations/0035_giant_hydra.sql create mode 100644 apps/server/src/db/migrations/0036_petite_mole_man.sql create mode 100644 apps/server/src/db/migrations/meta/0036_snapshot.json create mode 100644 apps/server/src/lib/templates-manager.ts create mode 100644 apps/server/src/trpc/routes/templates.ts diff --git a/apps/mail/components/create/email-composer.tsx b/apps/mail/components/create/email-composer.tsx index 54b1d98ff6..74e2a41f7d 100644 --- a/apps/mail/components/create/email-composer.tsx +++ b/apps/mail/components/create/email-composer.tsx @@ -47,6 +47,7 @@ import pluralize from 'pluralize'; import { toast } from 'sonner'; import { z } from 'zod'; const shortcodeRegex = /:([a-zA-Z0-9_+-]+):/g; +import { TemplateButton } from './template-button'; type ThreadContent = { from: string; @@ -1322,6 +1323,15 @@ export function EmailComposer({ Add + setValue('subject', value)} + to={toEmails} + cc={ccEmails ?? []} + bcc={bccEmails ?? []} + setRecipients={(field, val) => setValue(field, val)} + /> void; + to: string[]; + cc: string[]; + bcc: string[]; + setRecipients: (field: RecipientField, value: string[]) => void; +} + +const TemplateButtonComponent: React.FC = ({ + editor, + subject, + setSubject, + to, + cc, + bcc, + setRecipients, +}) => { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data } = useTemplates(); + + const templates: Template[] = data?.templates ?? []; + + const [menuOpen, setMenuOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [templateName, setTemplateName] = useState(''); + const [search, setSearch] = useState(''); + + const deferredSearch = useDeferredValue(search); + + const filteredTemplates = useMemo(() => { + if (!deferredSearch.trim()) return templates; + return templates.filter((t) => + t.name.toLowerCase().includes(deferredSearch.toLowerCase()), + ); + }, [deferredSearch, templates]); + + const { mutateAsync: createTemplate } = useMutation(trpc.templates.create.mutationOptions()); + const { mutateAsync: deleteTemplateMutation } = useMutation( + trpc.templates.delete.mutationOptions(), + ); + + const handleSaveTemplate = async () => { + if (!editor) return; + if (!templateName.trim()) { + toast.error('Please provide a name'); + return; + } + + setIsSaving(true); + try { + const newTemplate = await createTemplate({ + name: templateName.trim(), + subject: subject || '', + body: editor.getHTML(), + to: to.length ? to : undefined, + cc: cc.length ? cc : undefined, + bcc: bcc.length ? bcc : undefined, + }); + queryClient.setQueryData(trpc.templates.list.queryKey(), (old: TemplatesQueryData) => { + if (!old?.templates) return old; + return { + templates: [newTemplate.template, ...old.templates], + }; + }); + toast.success('Template saved'); + setTemplateName(''); + setSaveDialogOpen(false); + } catch (error) { + if (error instanceof TRPCClientError) { + toast.error(error.message); + } else { + toast.error('Failed to save template'); + } + } finally { + setIsSaving(false); + } + }; + + const handleApplyTemplate = useCallback((template: Template) => { + if (!editor) return; + startTransition(() => { + if (template.subject) setSubject(template.subject); + if (template.body) editor.commands.setContent(template.body, false); + if (template.to) setRecipients('to', template.to); + if (template.cc) setRecipients('cc', template.cc); + if (template.bcc) setRecipients('bcc', template.bcc); + }); + }, [editor, setSubject, setRecipients]); + + const handleDeleteTemplate = useCallback( + async (templateId: string) => { + try { + await deleteTemplateMutation({ id: templateId }); + await queryClient.invalidateQueries({ + queryKey: trpc.templates.list.queryKey(), + }); + toast.success('Template deleted'); + } catch (err) { + if (err instanceof TRPCClientError) { + toast.error(err.message); + } else { + toast.error('Failed to delete template'); + } + } + }, + [deleteTemplateMutation, queryClient, trpc.templates.list], + ); + + return ( + <> + + + + + + { + setMenuOpen(false); + setSaveDialogOpen(true); + }} + disabled={isSaving} + > + Save current as template + + {templates.length > 0 ? ( + + + Use template + + +
+ setSearch(e.target.value)} + className="h-8 text-sm" + autoFocus + /> +
+
+ {filteredTemplates.map((t: Template) => ( + handleApplyTemplate(t)} + > + {t.name} + + + ))} + {filteredTemplates.length === 0 && ( +
No templates
+ )} +
+
+
+ ) : null} +
+
+ + + + + Save as Template + +
+ setTemplateName(e.target.value)} + autoFocus + /> +
+ + + + +
+
+ + ); +}; + +export const TemplateButton = React.memo(TemplateButtonComponent); \ No newline at end of file diff --git a/apps/mail/components/ui/toast.tsx b/apps/mail/components/ui/toast.tsx index 70e231e123..4947b7557e 100644 --- a/apps/mail/components/ui/toast.tsx +++ b/apps/mail/components/ui/toast.tsx @@ -24,7 +24,7 @@ const Toaster = () => { description: '!text-black dark:!text-white text-xs', toast: 'p-1', actionButton: - 'inline-flex h-7 items-center justify-center gap-1 overflow-hidden !rounded-md border px-1.5 dark:border-none !bg-[#E0E0E0] dark:!bg-[#424242]', + 'inline-flex h-7 items-center justify-center gap-1 overflow-hidden !rounded-md border px-1.5 dark:border-none !bg-[#E0E0E0] dark:!bg-[#424242] pointer-events-auto cursor-pointer', cancelButton: 'inline-flex h-7 items-center justify-center gap-1 overflow-hidden !rounded-md border px-1.5 dark:border-none !bg-[#E0E0E0] dark:!bg-[#424242]', closeButton: diff --git a/apps/mail/hooks/use-templates.ts b/apps/mail/hooks/use-templates.ts new file mode 100644 index 0000000000..961cfd3f85 --- /dev/null +++ b/apps/mail/hooks/use-templates.ts @@ -0,0 +1,11 @@ +import { useTRPC } from '@/providers/query-provider'; +import { useQuery } from '@tanstack/react-query'; + +export const useTemplates = () => { + const trpc = useTRPC(); + return useQuery( + trpc.templates.list.queryOptions(void 0, { + staleTime: 1000 * 60 * 5, + }), + ); +}; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0035_giant_hydra.sql b/apps/server/src/db/migrations/0035_giant_hydra.sql new file mode 100644 index 0000000000..c6468a637a --- /dev/null +++ b/apps/server/src/db/migrations/0035_giant_hydra.sql @@ -0,0 +1,14 @@ +CREATE TABLE "mail0_email_template" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "name" text NOT NULL, + "subject" text, + "body" text, + "to" jsonb, + "cc" jsonb, + "bcc" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "mail0_email_template" ADD CONSTRAINT "mail0_email_template_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0036_petite_mole_man.sql b/apps/server/src/db/migrations/0036_petite_mole_man.sql new file mode 100644 index 0000000000..c2c0e6d622 --- /dev/null +++ b/apps/server/src/db/migrations/0036_petite_mole_man.sql @@ -0,0 +1,2 @@ +CREATE INDEX "idx_mail0_email_template_user_id" ON "mail0_email_template" USING btree ("user_id");--> statement-breakpoint +ALTER TABLE "mail0_email_template" ADD CONSTRAINT "mail0_email_template_user_id_name_unique" UNIQUE("user_id","name"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0036_snapshot.json b/apps/server/src/db/migrations/meta/0036_snapshot.json new file mode 100644 index 0000000000..cdd7eb6559 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0036_snapshot.json @@ -0,0 +1,1228 @@ +{ + "id": "ebbddbfd-e2a7-46f8-86ef-c463e9a4ade4", + "prevId": "84f3f315-e07b-4821-ac47-720e4f2c209e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mail0_account": { + "name": "mail0_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_account_user_id_mail0_user_id_fk": { + "name": "mail0_account_user_id_mail0_user_id_fk", + "tableFrom": "mail0_account", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_connection": { + "name": "mail0_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "picture": { + "name": "picture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_connection_user_id_mail0_user_id_fk": { + "name": "mail0_connection_user_id_mail0_user_id_fk", + "tableFrom": "mail0_connection", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_connection_user_id_email_unique": { + "name": "mail0_connection_user_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_early_access": { + "name": "mail0_early_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_early_access": { + "name": "is_early_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_used_ticket": { + "name": "has_used_ticket", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_early_access_email_unique": { + "name": "mail0_early_access_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_email_template": { + "name": "mail0_email_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to": { + "name": "to", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cc": { + "name": "cc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bcc": { + "name": "bcc", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_mail0_email_template_user_id": { + "name": "idx_mail0_email_template_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mail0_email_template_user_id_mail0_user_id_fk": { + "name": "mail0_email_template_user_id_mail0_user_id_fk", + "tableFrom": "mail0_email_template", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_email_template_user_id_name_unique": { + "name": "mail0_email_template_user_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_jwks": { + "name": "mail0_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_note": { + "name": "mail0_note", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_note_user_id_mail0_user_id_fk": { + "name": "mail0_note_user_id_mail0_user_id_fk", + "tableFrom": "mail0_note", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_oauth_access_token": { + "name": "mail0_oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_oauth_access_token_access_token_unique": { + "name": "mail0_oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token" + ] + }, + "mail0_oauth_access_token_refresh_token_unique": { + "name": "mail0_oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_oauth_application": { + "name": "mail0_oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_u_r_ls": { + "name": "redirect_u_r_ls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_oauth_application_client_id_unique": { + "name": "mail0_oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_oauth_consent": { + "name": "mail0_oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_session": { + "name": "mail0_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_session_user_id_mail0_user_id_fk": { + "name": "mail0_session_user_id_mail0_user_id_fk", + "tableFrom": "mail0_session", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_session_token_unique": { + "name": "mail0_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_summary": { + "name": "mail0_summary", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_reply": { + "name": "suggested_reply", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_user": { + "name": "mail0_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "default_connection_id": { + "name": "default_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_prompt": { + "name": "custom_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number_verified": { + "name": "phone_number_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_user_email_unique": { + "name": "mail0_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "mail0_user_phone_number_unique": { + "name": "mail0_user_phone_number_unique", + "nullsNotDistinct": false, + "columns": [ + "phone_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_user_hotkeys": { + "name": "mail0_user_hotkeys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "shortcuts": { + "name": "shortcuts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_user_hotkeys_user_id_mail0_user_id_fk": { + "name": "mail0_user_hotkeys_user_id_mail0_user_id_fk", + "tableFrom": "mail0_user_hotkeys", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_user_settings": { + "name": "mail0_user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"language\":\"en\",\"timezone\":\"UTC\",\"dynamicContent\":false,\"externalImages\":true,\"customPrompt\":\"\",\"trustedSenders\":[],\"isOnboarded\":false,\"colorTheme\":\"system\",\"zeroSignature\":true,\"autoRead\":true,\"defaultEmailAlias\":\"\",\"categories\":[{\"id\":\"Important\",\"name\":\"Important\",\"searchValue\":\"is:important NOT is:sent NOT is:draft\",\"order\":0,\"icon\":\"Lightning\",\"isDefault\":false},{\"id\":\"All Mail\",\"name\":\"All Mail\",\"searchValue\":\"NOT is:draft (is:inbox OR (is:sent AND to:me))\",\"order\":1,\"icon\":\"Mail\",\"isDefault\":true},{\"id\":\"Personal\",\"name\":\"Personal\",\"searchValue\":\"is:personal NOT is:sent NOT is:draft\",\"order\":2,\"icon\":\"User\",\"isDefault\":false},{\"id\":\"Promotions\",\"name\":\"Promotions\",\"searchValue\":\"is:promotions NOT is:sent NOT is:draft\",\"order\":3,\"icon\":\"Tag\",\"isDefault\":false},{\"id\":\"Updates\",\"name\":\"Updates\",\"searchValue\":\"is:updates NOT is:sent NOT is:draft\",\"order\":4,\"icon\":\"Bell\",\"isDefault\":false},{\"id\":\"Unread\",\"name\":\"Unread\",\"searchValue\":\"is:unread NOT is:sent NOT is:draft\",\"order\":5,\"icon\":\"ScanEye\",\"isDefault\":false}],\"imageCompression\":\"medium\"}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_user_settings_user_id_mail0_user_id_fk": { + "name": "mail0_user_settings_user_id_mail0_user_id_fk", + "tableFrom": "mail0_user_settings", + "tableTo": "mail0_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail0_user_settings_user_id_unique": { + "name": "mail0_user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_verification": { + "name": "mail0_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail0_writing_style_matrix": { + "name": "mail0_writing_style_matrix", + "schema": "", + "columns": { + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "numMessages": { + "name": "numMessages", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "style": { + "name": "style", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mail0_writing_style_matrix_connectionId_mail0_connection_id_fk": { + "name": "mail0_writing_style_matrix_connectionId_mail0_connection_id_fk", + "tableFrom": "mail0_writing_style_matrix", + "tableTo": "mail0_connection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mail0_writing_style_matrix_connectionId_pk": { + "name": "mail0_writing_style_matrix_connectionId_pk", + "columns": [ + "connectionId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 530acb75a0..f481189c74 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -257,6 +257,20 @@ { "idx": 35, "version": "7", + "when": 1751340197573, + "tag": "0035_giant_hydra", + "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1751344587658, + "tag": "0036_petite_mole_man", + "breakpoints": true + }, + { + "idx": 37, + "version": "7", "when": 1751568728663, "tag": "0035_uneven_shiva", "breakpoints": true diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index bbe8016dee..65ac0a0195 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -297,3 +297,25 @@ export const oauthConsent = createTable( index('oauth_consent_given_idx').on(t.consentGiven), ], ); + +export const emailTemplate = createTable( + 'email_template', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + subject: text('subject'), + body: text('body'), + to: jsonb('to'), + cc: jsonb('cc'), + bcc: jsonb('bcc'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (t) => [ + index('idx_mail0_email_template_user_id').on(t.userId), + unique('mail0_email_template_user_id_name_unique').on(t.userId, t.name), + ], +); diff --git a/apps/server/src/lib/templates-manager.ts b/apps/server/src/lib/templates-manager.ts new file mode 100644 index 0000000000..6acfc0a1ec --- /dev/null +++ b/apps/server/src/lib/templates-manager.ts @@ -0,0 +1,89 @@ +import { getZeroDB } from './server-utils'; +import { randomUUID } from 'node:crypto'; +import { TRPCError } from '@trpc/server'; + +type EmailTemplate = { + id: string; + userId: string; + name: string; + subject: string | null; + body: string | null; + to: string[] | null; + cc: string[] | null; + bcc: string[] | null; + createdAt: Date; + updatedAt: Date; +}; + +export class TemplatesManager { + async listTemplates(userId: string) { + const db = await getZeroDB(userId); + return await db.listEmailTemplates(); + } + + async createTemplate( + userId: string, + payload: { + id?: string; + name: string; + subject?: string | null; + body?: string | null; + to?: string[] | null; + cc?: string[] | null; + bcc?: string[] | null; + }, + ) { + if (payload.name.length > 100) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Template name must be at most 100 characters', + }); + } + + if (payload.subject && payload.subject.length > 500) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Template subject must be at most 500 characters', + }); + } + + if (payload.body && payload.body.length > 50000) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Template body must be at most 50,000 characters', + }); + } + + const db = await getZeroDB(userId); + + const existingTemplates = (await db.listEmailTemplates()) as EmailTemplate[]; + const nameExists = existingTemplates.some((template: EmailTemplate) => + template.name.toLowerCase() === payload.name.toLowerCase() + ); + + if (nameExists) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `A template named "${payload.name}" already exists. Please choose a different name.`, + }); + } + + const id = payload.id ?? randomUUID(); + const [template] = await db.createEmailTemplate({ + id, + name: payload.name, + subject: payload.subject ?? null, + body: payload.body ?? null, + to: payload.to ?? null, + cc: payload.cc ?? null, + bcc: payload.bcc ?? null, + }) as EmailTemplate[]; + return template; + } + + async deleteTemplate(userId: string, templateId: string) { + const db = await getZeroDB(userId); + await db.deleteEmailTemplate(templateId); + return true; + } +} \ No newline at end of file diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index a2f6c835fd..7b3d491d10 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,6 +13,7 @@ import { userHotkeys, userSettings, writingStyleMatrix, + emailTemplate, } from './db/schema'; import { env, DurableObject, RpcTarget, WorkerEntrypoint } from 'cloudflare:workers'; import { EProviders, type ISubscribeBatch, type IThreadBatch } from './types'; @@ -173,6 +174,25 @@ export class DbRpcDO extends RpcTarget { ) { return await this.mainDo.updateConnection(connectionId, updatingInfo); } + + async listEmailTemplates(): Promise<(typeof emailTemplate.$inferSelect)[]> { + return await this.mainDo.findManyEmailTemplates(this.userId); + } + + async createEmailTemplate(payload: Omit) { + return await this.mainDo.createEmailTemplate(this.userId, payload); + } + + async deleteEmailTemplate(templateId: string) { + return await this.mainDo.deleteEmailTemplate(this.userId, templateId); + } + + async updateEmailTemplate( + templateId: string, + data: Partial, + ) { + return await this.mainDo.updateEmailTemplate(this.userId, templateId, data); + } } class ZeroDB extends DurableObject { @@ -494,6 +514,44 @@ class ZeroDB extends DurableObject { .set(updatingInfo) .where(eq(connection.id, connectionId)); } + + async findManyEmailTemplates(userId: string): Promise<(typeof emailTemplate.$inferSelect)[]> { + return await this.db.query.emailTemplate.findMany({ + where: eq(emailTemplate.userId, userId), + orderBy: desc(emailTemplate.updatedAt), + }); + } + + async createEmailTemplate(userId: string, payload: Omit) { + return await this.db + .insert(emailTemplate) + .values({ + ...payload, + userId, + id: crypto.randomUUID(), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + } + + async deleteEmailTemplate(userId: string, templateId: string) { + return await this.db + .delete(emailTemplate) + .where(and(eq(emailTemplate.id, templateId), eq(emailTemplate.userId, userId))); + } + + async updateEmailTemplate( + userId: string, + templateId: string, + data: Partial, + ) { + return await this.db + .update(emailTemplate) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(emailTemplate.id, templateId), eq(emailTemplate.userId, userId))) + .returning(); + } } const api = new Hono() diff --git a/apps/server/src/trpc/index.ts b/apps/server/src/trpc/index.ts index d629790257..4d9570b9e4 100644 --- a/apps/server/src/trpc/index.ts +++ b/apps/server/src/trpc/index.ts @@ -1,7 +1,6 @@ import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; import { cookiePreferencesRouter } from './routes/cookies'; import { connectionsRouter } from './routes/connections'; -import { categoriesRouter } from './routes/categories'; import { shortcutRouter } from './routes/shortcut'; import { settingsRouter } from './routes/settings'; import { getContext } from 'hono/context-storage'; @@ -15,6 +14,8 @@ import { bimiRouter } from './routes/bimi'; import type { HonoContext } from '../ctx'; import { aiRouter } from './routes/ai'; import { router } from './trpc'; +import { categoriesRouter } from './routes/categories'; +import { templatesRouter } from './routes/templates'; export const appRouter = router({ ai: aiRouter, @@ -30,6 +31,7 @@ export const appRouter = router({ shortcut: shortcutRouter, settings: settingsRouter, user: userRouter, + templates: templatesRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/server/src/trpc/routes/templates.ts b/apps/server/src/trpc/routes/templates.ts new file mode 100644 index 0000000000..a699194aa6 --- /dev/null +++ b/apps/server/src/trpc/routes/templates.ts @@ -0,0 +1,36 @@ +import { TemplatesManager } from '../../lib/templates-manager'; +import { privateProcedure, router } from '../trpc'; +import { z } from 'zod'; + +const templatesProcedure = privateProcedure.use(async ({ ctx, next }) => { + const templatesManager = new TemplatesManager(); + return next({ ctx: { ...ctx, templatesManager } }); +}); + +export const templatesRouter = router({ + list: templatesProcedure.query(async ({ ctx }) => { + const templates = await ctx.templatesManager.listTemplates(ctx.sessionUser.id); + return { templates }; + }), + create: templatesProcedure + .input( + z.object({ + name: z.string().min(1), + subject: z.string().default(''), + body: z.string().default(''), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const template = await ctx.templatesManager.createTemplate(ctx.sessionUser.id, input); + return { template }; + }), + delete: templatesProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + await ctx.templatesManager.deleteTemplate(ctx.sessionUser.id, input.id); + return { success: true }; + }), +}); \ No newline at end of file From 803a6caedb6daaf17b82b2324858d6f70fc25e63 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:29:31 -0700 Subject: [PATCH 26/48] Implement workflow chain execution to share results between workflows (#1872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added workflow chain execution so results can be shared between workflows, enabling workflows to pass data to each other during execution. - **New Features** - Introduced `executeWorkflowChain` to run multiple workflows in sequence and share results. - Updated workflow runner to use the new chain execution method. ## Summary by CodeRabbit * **Bug Fixes** * Improved accuracy in counting successfully synced threads during workflow execution. * Workflows now proceed regardless of certain environment variable settings. * **New Features** * Added support for executing multiple workflows in sequence with consolidated result and error reporting. * **Refactor** * Simplified and streamlined workflow execution logic for better maintainability. * **Style** * Enhanced code formatting for improved readability. * **Documentation** * Updated method signatures to clarify optional parameters. --- apps/server/src/main.ts | 10 ++-- apps/server/src/pipelines.ts | 34 +++----------- apps/server/src/routes/agent/index.ts | 17 +++++-- .../thread-workflow-utils/workflow-engine.ts | 46 +++++++++++++++++-- 4 files changed, 66 insertions(+), 41 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7b3d491d10..88b11e2d37 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -187,10 +187,7 @@ export class DbRpcDO extends RpcTarget { return await this.mainDo.deleteEmailTemplate(this.userId, templateId); } - async updateEmailTemplate( - templateId: string, - data: Partial, - ) { + async updateEmailTemplate(templateId: string, data: Partial) { return await this.mainDo.updateEmailTemplate(this.userId, templateId, data); } } @@ -522,7 +519,10 @@ class ZeroDB extends DurableObject { }); } - async createEmailTemplate(userId: string, payload: Omit) { + async createEmailTemplate( + userId: string, + payload: Omit, + ) { return await this.db .insert(emailTemplate) .values({ diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index efd118edce..35b0c375ee 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -393,7 +393,7 @@ export class WorkflowRunner extends DurableObject { { concurrency: 6 }, // Limit concurrency to avoid rate limits ); - const syncedCount = syncResults.length; + const syncedCount = syncResults.filter((result) => result.result.success).length; const failedCount = threadWorkflowParams.length - syncedCount; if (failedCount > 0) { @@ -627,37 +627,15 @@ export class WorkflowRunner extends DurableObject { // Execute configured workflows using the workflow engine const workflowResults = yield* Effect.tryPromise({ try: async () => { - const allResults = new Map(); - const allErrors = new Map(); - // Execute all workflows registered in the engine const workflowNames = workflowEngine.getWorkflowNames(); - for (const workflowName of workflowNames) { - console.log(`[THREAD_WORKFLOW] Executing workflow: ${workflowName}`); - - try { - const { results, errors } = await workflowEngine.executeWorkflow( - workflowName, - workflowContext, - ); - - // Merge results and errors using efficient Map operations - results.forEach((value, key) => allResults.set(key, value)); - errors.forEach((value, key) => allErrors.set(key, value)); - - console.log(`[THREAD_WORKFLOW] Completed workflow: ${workflowName}`); - } catch (error) { - console.error( - `[THREAD_WORKFLOW] Failed to execute workflow ${workflowName}:`, - error, - ); - const errorObj = error instanceof Error ? error : new Error(String(error)); - allErrors.set(workflowName, errorObj); - } - } + const { results, errors } = await workflowEngine.executeWorkflowChain( + workflowNames, + workflowContext, + ); - return { results: allResults, errors: allErrors }; + return { results, errors }; }, catch: (error) => ({ _tag: 'WorkflowCreationFailed' as const, error }), }); diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index d8118be985..c8ef379f99 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -1779,7 +1779,7 @@ export class ZeroAgent extends AIChatAgent { private getDataStreamResponse( onFinish: StreamTextOnFinishCallback<{}>, options?: { - abortSignal: AbortSignal | undefined; + abortSignal?: AbortSignal; connectionId?: string; }, ) { @@ -1822,7 +1822,10 @@ export class ZeroAgent extends AIChatAgent { onError: (error) => { console.error('Error in streamText', error); }, - system: await getPrompt(getPromptName(connectionId, EPrompts.Chat), AiChatPrompt(currentThreadId)), + system: await getPrompt( + getPromptName(connectionId, EPrompts.Chat), + AiChatPrompt(currentThreadId), + ), }); result.mergeIntoDataStream(dataStream); @@ -1912,7 +1915,9 @@ export class ZeroAgent extends AIChatAgent { await this.persistMessages(finalMessages, [connection.id]); this.removeAbortController(chatMessageId); }, - abortSignal ? { abortSignal, connectionId: connection.id } : { connectionId: connection.id }, + abortSignal + ? { abortSignal, connectionId: connection.id } + : { connectionId: connection.id }, ); if (response) { @@ -1955,7 +1960,9 @@ export class ZeroAgent extends AIChatAgent { } case IncomingMessageType.ThreadIdUpdate: { this.connectionThreadIds.set(connection.id, data.threadId); - console.log(`[ZeroAgent] Updated threadId for connection ${connection.id}: ${data.threadId}`); + console.log( + `[ZeroAgent] Updated threadId for connection ${connection.id}: ${data.threadId}`, + ); break; } // case IncomingMessageType.Mail_List: { @@ -2023,7 +2030,7 @@ export class ZeroAgent extends AIChatAgent { async onChatMessage( onFinish: StreamTextOnFinishCallback<{}>, options?: { - abortSignal: AbortSignal | undefined; + abortSignal?: AbortSignal; connectionId?: string; }, ) { diff --git a/apps/server/src/thread-workflow-utils/workflow-engine.ts b/apps/server/src/thread-workflow-utils/workflow-engine.ts index 05709b1908..07b5fd4ce6 100644 --- a/apps/server/src/thread-workflow-utils/workflow-engine.ts +++ b/apps/server/src/thread-workflow-utils/workflow-engine.ts @@ -42,13 +42,14 @@ export class WorkflowEngine { async executeWorkflow( workflowName: string, context: WorkflowContext, + existingResults?: Map, ): Promise<{ results: Map; errors: Map }> { const workflow = this.workflows.get(workflowName); if (!workflow) { throw new Error(`Workflow "${workflowName}" not found`); } - const results = new Map(); + const results = new Map(existingResults || []); const errors = new Map(); for (const step of workflow.steps) { @@ -83,6 +84,45 @@ export class WorkflowEngine { return { results, errors }; } + async executeWorkflowChain( + workflowNames: string[], + context: WorkflowContext, + ): Promise<{ results: Map; errors: Map }> { + let sharedResults = new Map(); + let allErrors = new Map(); + + for (const workflowName of workflowNames) { + console.log(`[WORKFLOW_ENGINE] Executing workflow in chain: ${workflowName}`); + try { + const { results, errors } = await this.executeWorkflow( + workflowName, + context, + sharedResults, + ); + + // Merge results + for (const [key, value] of results) { + sharedResults.set(key, value); + } + + // Merge errors + for (const [key, error] of errors) { + allErrors.set(key, error); + } + + console.log( + `[WORKFLOW_ENGINE] Completed workflow: ${workflowName}, total results: ${sharedResults.size}`, + ); + } catch (error) { + const errorObj = error instanceof Error ? error : new Error(String(error)); + console.error(`[WORKFLOW_ENGINE] Failed to execute workflow ${workflowName}:`, errorObj); + allErrors.set(workflowName, errorObj); + } + } + + return { results: sharedResults, errors: allErrors }; + } + clearContext(context: WorkflowContext): void { if (context.results) { context.results.clear(); @@ -158,7 +198,7 @@ export const createDefaultWorkflows = (): WorkflowEngine => { ], }; - const _vectorizationWorkflow: WorkflowDefinition = { + const vectorizationWorkflow: WorkflowDefinition = { name: 'message-vectorization', description: 'Vectorizes thread messages for search and analysis', steps: [ @@ -272,7 +312,7 @@ export const createDefaultWorkflows = (): WorkflowEngine => { }; engine.registerWorkflow(autoDraftWorkflow); - // engine.registerWorkflow(vectorizationWorkflow); + engine.registerWorkflow(vectorizationWorkflow); engine.registerWorkflow(threadSummaryWorkflow); engine.registerWorkflow(labelGenerationWorkflow); From 78987ea5883b9b53de1925c2ba3e1af06bdbec78 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:47:27 -0700 Subject: [PATCH 27/48] Add email composition and sending tools to MCP agent (#1874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ --- ## Summary by cubic Added new tools to the MCP agent for composing and sending emails directly through the agent interface. - **New Features** - Added a tool to generate email drafts based on prompts and thread context. - Added a tool to send emails, including support for drafts, CC, and BCC. --- apps/server/src/routes/agent/mcp.ts | 118 ++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/apps/server/src/routes/agent/mcp.ts b/apps/server/src/routes/agent/mcp.ts index 16ccd0d92a..d627a19c25 100644 --- a/apps/server/src/routes/agent/mcp.ts +++ b/apps/server/src/routes/agent/mcp.ts @@ -15,6 +15,7 @@ */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { composeEmail } from '../../trpc/routes/ai/compose'; import { getCurrentDateContext } from '../../lib/prompts'; import { getZeroAgent } from '../../lib/server-utils'; import { connection } from '../../db/schema'; @@ -118,6 +119,123 @@ export class ZeroMCP extends McpAgent, { use const agent = await getZeroAgent(_connection.id); + this.server.registerTool( + 'composeEmail', + { + description: 'Compose an email using AI assistance', + inputSchema: { + prompt: z.string(), + emailSubject: z.string().optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + threadMessages: z + .array( + z.object({ + from: z.string(), + to: z.array(z.string()), + cc: z.array(z.string()).optional(), + subject: z.string(), + body: z.string(), + }), + ) + .optional(), + }, + }, + async (data) => { + if (!this.activeConnectionId) { + throw new Error('No active connection'); + } + const newBody = await composeEmail({ + prompt: data.prompt, + emailSubject: data.emailSubject, + to: data.to, + cc: data.cc, + threadMessages: data.threadMessages, + username: 'AI Assistant', + connectionId: this.activeConnectionId, + }); + return { + content: [ + { + type: 'text' as const, + text: newBody, + }, + ], + }; + }, + ); + + this.server.registerTool( + 'sendEmail', + { + description: 'Send a new email', + inputSchema: { + to: z.array( + z.object({ + email: z.string(), + name: z.string().optional(), + }), + ), + subject: z.string(), + message: z.string(), + cc: z + .array( + z.object({ + email: z.string(), + name: z.string().optional(), + }), + ) + .optional(), + bcc: z + .array( + z.object({ + email: z.string(), + name: z.string().optional(), + }), + ) + .optional(), + threadId: z.string().optional(), + draftId: z.string().optional(), + }, + }, + async (data) => { + if (!this.activeConnectionId) { + throw new Error('No active connection'); + } + try { + const { draftId, ...mail } = data; + + if (draftId) { + await agent.sendDraft(draftId, { + ...mail, + attachments: [], + headers: {}, + }); + } else { + await agent.create({ + ...mail, + attachments: [], + headers: {}, + }); + } + + return { + content: [ + { + type: 'text' as const, + text: 'Email sent successfully', + }, + ], + }; + } catch (error) { + console.error('Error sending email:', error); + throw new Error( + 'Failed to send email: ' + (error instanceof Error ? error.message : String(error)), + ); + } + }, + ); + this.server.registerTool( 'listThreads', { From 75729475315d8ef9df0381e548fd4617b3d9747d Mon Sep 17 00:00:00 2001 From: Ahmet Kilinc Date: Fri, 1 Aug 2025 16:17:49 +0100 Subject: [PATCH 28/48] HMR fix (#1882) fixes HMR issue. ### !!! test on staging first. ## Summary by CodeRabbit * **Refactor** * Simplified the root layout by removing dynamic loading and usage of the connection ID. The connection ID is now always set to null. --- apps/mail/app/root.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/mail/app/root.tsx b/apps/mail/app/root.tsx index 8abe90401c..dc6134447f 100644 --- a/apps/mail/app/root.tsx +++ b/apps/mail/app/root.tsx @@ -55,18 +55,7 @@ export const meta: MetaFunction = () => { ]; }; -export async function loader(_: LoaderFunctionArgs) { - // const trpc = getServerTrpc(request); - // const defaultConnection = await trpc.connections.getDefault - // .query() - // .then((res) => (res?.id as string) ?? null) - // .catch(() => null); - return { connectionId: 'defaultConnection' }; -} - export function Layout({ children }: PropsWithChildren) { - const { connectionId } = useLoaderData(); - return ( @@ -82,7 +71,7 @@ export function Layout({ children }: PropsWithChildren) { - + {children} Date: Fri, 1 Aug 2025 08:27:16 -0700 Subject: [PATCH 29/48] feat: update translations via @LingoDotDev (#1858) Hey team, [**Lingo.dev**](https://lingo.dev) here with fresh translations! ### In this update - Added missing translations - Performed brand voice, context and glossary checks - Enhanced translations using Lingo.dev Localization Engine ### Next Steps - [ ] Review the changes - [ ] Merge when ready --- ## Summary by cubic Updated translations for all supported languages in the mail app, adding new keys for "animations" and improving existing text for clarity and consistency. --- apps/mail/messages/ar.json | 4 +++- apps/mail/messages/ca.json | 4 +++- apps/mail/messages/cs.json | 4 +++- apps/mail/messages/de.json | 4 +++- apps/mail/messages/es.json | 4 +++- apps/mail/messages/fa.json | 4 +++- apps/mail/messages/fr.json | 4 +++- apps/mail/messages/hi.json | 4 +++- apps/mail/messages/hu.json | 4 +++- apps/mail/messages/ja.json | 4 +++- apps/mail/messages/ko.json | 4 +++- apps/mail/messages/lv.json | 4 +++- apps/mail/messages/nl.json | 4 +++- apps/mail/messages/pl.json | 4 +++- apps/mail/messages/pt.json | 4 +++- apps/mail/messages/ru.json | 4 +++- apps/mail/messages/tr.json | 4 +++- apps/mail/messages/vi.json | 4 +++- apps/mail/messages/zh_CN.json | 4 +++- apps/mail/messages/zh_TW.json | 4 +++- i18n.lock | 2 ++ 21 files changed, 62 insertions(+), 20 deletions(-) diff --git a/apps/mail/messages/ar.json b/apps/mail/messages/ar.json index 51be6b2020..7bd87d9050 100644 --- a/apps/mail/messages/ar.json +++ b/apps/mail/messages/ar.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "اختر البريد الإلكتروني الافتراضي", "defaultEmailDescription": "سيتم استخدام هذا البريد الإلكتروني كعنوان \"من\" الافتراضي عند إنشاء رسائل إلكترونية جديدة", "autoRead": "قراءة تلقائية", - "autoReadDescription": "وضع علامة على رسائل البريد الإلكتروني كمقروءة تلقائيًا عند النقر عليها." + "autoReadDescription": "وضع علامة على رسائل البريد الإلكتروني كمقروءة تلقائيًا عند النقر عليها.", + "animations": "الرسوم المتحركة", + "animationsDescription": "تمكين الرسوم المتحركة السلسة عند التبديل بين رسائل البريد الإلكتروني" }, "connections": { "title": "اتصالات البريد الإلكتروني", diff --git a/apps/mail/messages/ca.json b/apps/mail/messages/ca.json index 93a690903c..5492dc60ee 100644 --- a/apps/mail/messages/ca.json +++ b/apps/mail/messages/ca.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Selecciona el correu predeterminat", "defaultEmailDescription": "Aquest correu s'utilitzarà com a adreça predeterminada 'De' quan es redactin nous correus", "autoRead": "Lectura automàtica", - "autoReadDescription": "Marca automàticament els correus electrònics com a llegits quan hi fas clic." + "autoReadDescription": "Marca automàticament els correus electrònics com a llegits quan hi fas clic.", + "animations": "Animacions", + "animationsDescription": "Activa les animacions suaus en canviar entre correus electrònics" }, "connections": { "title": "Connexions Correu", diff --git a/apps/mail/messages/cs.json b/apps/mail/messages/cs.json index 6bf0a9b05f..27ae483697 100644 --- a/apps/mail/messages/cs.json +++ b/apps/mail/messages/cs.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Vybrat výchozí e-mail", "defaultEmailDescription": "Tento e-mail bude použit jako výchozí adresa 'Od' při psaní nových e-mailů", "autoRead": "Automatické přečtení", - "autoReadDescription": "Automaticky označit e-maily jako přečtené při kliknutí na ně." + "autoReadDescription": "Automaticky označit e-maily jako přečtené při kliknutí na ně.", + "animations": "Animace", + "animationsDescription": "Povolit plynulé animace při přepínání mezi e-maily" }, "connections": { "title": "Propojení emailu", diff --git a/apps/mail/messages/de.json b/apps/mail/messages/de.json index 5ad52ffc2a..0a1ad5a8dd 100644 --- a/apps/mail/messages/de.json +++ b/apps/mail/messages/de.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Standard-E-Mail auswählen", "defaultEmailDescription": "Diese E-Mail wird als Standard-Absenderadresse beim Verfassen neuer E-Mails verwendet", "autoRead": "Auto-Lesen", - "autoReadDescription": "E-Mails automatisch als gelesen markieren, wenn du sie anklickst." + "autoReadDescription": "E-Mails automatisch als gelesen markieren, wenn du sie anklickst.", + "animations": "Animationen", + "animationsDescription": "Aktiviere sanfte Animationen beim Wechseln zwischen E-Mails" }, "connections": { "title": "E-Mail Verbindungen", diff --git a/apps/mail/messages/es.json b/apps/mail/messages/es.json index f9bd196fe4..d09d01ca33 100644 --- a/apps/mail/messages/es.json +++ b/apps/mail/messages/es.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Seleccionar correo predeterminado", "defaultEmailDescription": "Este correo electrónico se utilizará como dirección predeterminada 'De' al redactar nuevos correos", "autoRead": "Lectura automática", - "autoReadDescription": "Marcar automáticamente los correos electrónicos como leídos cuando haces clic en ellos." + "autoReadDescription": "Marcar automáticamente los correos electrónicos como leídos cuando haces clic en ellos.", + "animations": "Animaciones", + "animationsDescription": "Activar animaciones suaves al cambiar entre correos electrónicos" }, "connections": { "title": "Conexiones de correo", diff --git a/apps/mail/messages/fa.json b/apps/mail/messages/fa.json index e6a031a238..18740f7b59 100644 --- a/apps/mail/messages/fa.json +++ b/apps/mail/messages/fa.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "انتخاب ایمیل پیش‌فرض", "defaultEmailDescription": "این ایمیل به عنوان آدرس پیش‌فرض 'از طرف' هنگام نوشتن ایمیل‌های جدید استفاده خواهد شد", "autoRead": "خواندن خودکار", - "autoReadDescription": "به طور خودکار ایمیل‌ها را هنگام کلیک کردن روی آن‌ها به عنوان خوانده شده علامت‌گذاری کنید." + "autoReadDescription": "به طور خودکار ایمیل‌ها را هنگام کلیک کردن روی آن‌ها به عنوان خوانده شده علامت‌گذاری کنید.", + "animations": "انیمیشن‌ها", + "animationsDescription": "فعال‌سازی انیمیشن‌های روان هنگام جابجایی بین ایمیل‌ها" }, "connections": { "title": "اتصالات ایمیل", diff --git a/apps/mail/messages/fr.json b/apps/mail/messages/fr.json index edccf3d83a..8f7fa58f3f 100644 --- a/apps/mail/messages/fr.json +++ b/apps/mail/messages/fr.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Sélectionner l'e-mail par défaut", "defaultEmailDescription": "Cette adresse e-mail sera utilisée comme adresse d'expédition par défaut lors de la rédaction de nouveaux e-mails", "autoRead": "Lecture automatique", - "autoReadDescription": "Marquer automatiquement les emails comme lus lorsque vous cliquez dessus." + "autoReadDescription": "Marquer automatiquement les emails comme lus lorsque vous cliquez dessus.", + "animations": "Animations", + "animationsDescription": "Activer les animations fluides lors du passage d'un e-mail à l'autre" }, "connections": { "title": "Connexions de courriels", diff --git a/apps/mail/messages/hi.json b/apps/mail/messages/hi.json index 984a827523..752337baf8 100644 --- a/apps/mail/messages/hi.json +++ b/apps/mail/messages/hi.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "डिफ़ॉल्ट ईमेल चुनें", "defaultEmailDescription": "नए ईमेल लिखते समय यह ईमेल डिफ़ॉल्ट 'प्रेषक' पते के रूप में उपयोग किया जाएगा", "autoRead": "ऑटो रीड", - "autoReadDescription": "जब आप ईमेल पर क्लिक करते हैं तो उन्हें स्वचालित रूप से पढ़ा हुआ चिह्नित करें।" + "autoReadDescription": "जब आप ईमेल पर क्लिक करते हैं तो उन्हें स्वचालित रूप से पढ़ा हुआ चिह्नित करें।", + "animations": "एनिमेशन", + "animationsDescription": "ईमेल के बीच स्विच करते समय सुचारू एनिमेशन सक्षम करें" }, "connections": { "title": "ईमेल कनेक्शन्स", diff --git a/apps/mail/messages/hu.json b/apps/mail/messages/hu.json index 96a37b50b8..395e1929bf 100644 --- a/apps/mail/messages/hu.json +++ b/apps/mail/messages/hu.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Alapértelmezett e-mail kiválasztása", "defaultEmailDescription": "Ez az e-mail lesz az alapértelmezett 'Feladó' cím új e-mailek írásakor", "autoRead": "Automatikus olvasás", - "autoReadDescription": "Automatikusan olvasottként jelöli az e-maileket, amikor rájuk kattintasz." + "autoReadDescription": "Automatikusan olvasottként jelöli az e-maileket, amikor rájuk kattintasz.", + "animations": "Animációk", + "animationsDescription": "Sima animációk engedélyezése e-mailek közötti váltáskor" }, "connections": { "title": "E-mail kapcsolatok", diff --git a/apps/mail/messages/ja.json b/apps/mail/messages/ja.json index cff9865252..57cb0214d2 100644 --- a/apps/mail/messages/ja.json +++ b/apps/mail/messages/ja.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "デフォルトのメールを選択", "defaultEmailDescription": "このメールは新しいメールを作成する際のデフォルトの「差出人」アドレスとして使用されます", "autoRead": "自動既読", - "autoReadDescription": "メールをクリックすると自動的に既読としてマークします。" + "autoReadDescription": "メールをクリックすると自動的に既読としてマークします。", + "animations": "アニメーション", + "animationsDescription": "メール間の切り替え時にスムーズなアニメーションを有効にする" }, "connections": { "title": "メールの接続", diff --git a/apps/mail/messages/ko.json b/apps/mail/messages/ko.json index 9c2d8660ed..a1c0aa3b9a 100644 --- a/apps/mail/messages/ko.json +++ b/apps/mail/messages/ko.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "기본 이메일 선택", "defaultEmailDescription": "이 이메일은 새 이메일 작성 시 기본 '보낸 사람' 주소로 사용됩니다", "autoRead": "자동 읽음", - "autoReadDescription": "이메일을 클릭하면 자동으로 읽음으로 표시합니다." + "autoReadDescription": "이메일을 클릭하면 자동으로 읽음으로 표시합니다.", + "animations": "애니메이션", + "animationsDescription": "이메일 간 전환 시 부드러운 애니메이션 활성화" }, "connections": { "title": "이메일 연결", diff --git a/apps/mail/messages/lv.json b/apps/mail/messages/lv.json index d59ea2fe62..f02050751c 100644 --- a/apps/mail/messages/lv.json +++ b/apps/mail/messages/lv.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Izvēlieties noklusējuma e-pastu", "defaultEmailDescription": "Šis e-pasts tiks izmantots kā noklusējuma 'No' adrese, rakstot jaunus e-pastus", "autoRead": "Automātiska lasīšana", - "autoReadDescription": "Automātiski atzīmēt e-pastus kā izlasītus, kad uz tiem uzklikšķināt." + "autoReadDescription": "Automātiski atzīmēt e-pastus kā izlasītus, kad uz tiem uzklikšķināt.", + "animations": "Animācijas", + "animationsDescription": "Iespējot plūstošas animācijas, pārslēdzoties starp e-pastiem" }, "connections": { "title": "E-pasta savienojumi", diff --git a/apps/mail/messages/nl.json b/apps/mail/messages/nl.json index b3798a4bdc..eb35f572dd 100644 --- a/apps/mail/messages/nl.json +++ b/apps/mail/messages/nl.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Selecteer standaard e-mail", "defaultEmailDescription": "Dit e-mailadres wordt gebruikt als standaard 'Van'-adres bij het opstellen van nieuwe e-mails", "autoRead": "Automatisch lezen", - "autoReadDescription": "E-mails automatisch als gelezen markeren wanneer je erop klikt." + "autoReadDescription": "E-mails automatisch als gelezen markeren wanneer je erop klikt.", + "animations": "Animaties", + "animationsDescription": "Schakel vloeiende animaties in bij het wisselen tussen e-mails" }, "connections": { "title": "E-mailverbindingen", diff --git a/apps/mail/messages/pl.json b/apps/mail/messages/pl.json index 5d696ad6a7..f9b908ca12 100644 --- a/apps/mail/messages/pl.json +++ b/apps/mail/messages/pl.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Wybierz domyślny e-mail", "defaultEmailDescription": "Ten e-mail będzie używany jako domyślny adres 'Od' podczas tworzenia nowych wiadomości e-mail", "autoRead": "Auto Read", - "autoReadDescription": "Automatycznie oznaczaj e-maile jako przeczytane po ich kliknięciu." + "autoReadDescription": "Automatycznie oznaczaj e-maile jako przeczytane po ich kliknięciu.", + "animations": "Animacje", + "animationsDescription": "Włącz płynne animacje podczas przełączania między e-mailami" }, "connections": { "title": "Połączenia e-mail", diff --git a/apps/mail/messages/pt.json b/apps/mail/messages/pt.json index 680c2e2461..e634734b62 100644 --- a/apps/mail/messages/pt.json +++ b/apps/mail/messages/pt.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Selecionar email padrão", "defaultEmailDescription": "Este email será usado como endereço 'De' padrão ao compor novos emails", "autoRead": "Leitura automática", - "autoReadDescription": "Marcar automaticamente os emails como lidos quando você clicar neles." + "autoReadDescription": "Marcar automaticamente os emails como lidos quando você clicar neles.", + "animations": "Animações", + "animationsDescription": "Ativar animações suaves ao alternar entre e-mails" }, "connections": { "title": "Conexões de E-mail", diff --git a/apps/mail/messages/ru.json b/apps/mail/messages/ru.json index 9ca153f84e..59e0772e95 100644 --- a/apps/mail/messages/ru.json +++ b/apps/mail/messages/ru.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Выберите адрес электронной почты по умолчанию", "defaultEmailDescription": "Этот адрес будет использоваться в качестве адреса отправителя по умолчанию при создании новых писем", "autoRead": "Автоматическое чтение", - "autoReadDescription": "Автоматически помечать электронные письма как прочитанные при их открытии." + "autoReadDescription": "Автоматически помечать электронные письма как прочитанные при их открытии.", + "animations": "Анимации", + "animationsDescription": "Включить плавные анимации при переключении между письмами" }, "connections": { "title": "Подключения", diff --git a/apps/mail/messages/tr.json b/apps/mail/messages/tr.json index 2fd107d125..26deb90e83 100644 --- a/apps/mail/messages/tr.json +++ b/apps/mail/messages/tr.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Varsayılan e-postayı seç", "defaultEmailDescription": "Bu e-posta, yeni e-postalar oluştururken varsayılan 'Kimden' adresi olarak kullanılacaktır", "autoRead": "Otomatik Okuma", - "autoReadDescription": "E-postalara tıkladığınızda otomatik olarak okundu olarak işaretlenir." + "autoReadDescription": "E-postalara tıkladığınızda otomatik olarak okundu olarak işaretlenir.", + "animations": "Animasyonlar", + "animationsDescription": "E-postalar arasında geçiş yaparken akıcı animasyonları etkinleştir" }, "connections": { "title": "E-posta Bağlantıları", diff --git a/apps/mail/messages/vi.json b/apps/mail/messages/vi.json index 814fece645..aaca789c77 100644 --- a/apps/mail/messages/vi.json +++ b/apps/mail/messages/vi.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "Chọn email mặc định", "defaultEmailDescription": "Email này sẽ được sử dụng làm địa chỉ 'Từ' mặc định khi soạn email mới", "autoRead": "Tự động đọc", - "autoReadDescription": "Tự động đánh dấu email là đã đọc khi bạn nhấp vào chúng." + "autoReadDescription": "Tự động đánh dấu email là đã đọc khi bạn nhấp vào chúng.", + "animations": "Hiệu ứng động", + "animationsDescription": "Bật hiệu ứng chuyển động mượt mà khi chuyển đổi giữa các email" }, "connections": { "title": "Kết nối email", diff --git a/apps/mail/messages/zh_CN.json b/apps/mail/messages/zh_CN.json index 9e95378cdb..c69d4621b3 100644 --- a/apps/mail/messages/zh_CN.json +++ b/apps/mail/messages/zh_CN.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "选择默认邮箱", "defaultEmailDescription": "此邮箱将作为撰写新邮件时的默认\"发件人\"地址", "autoRead": "自动阅读", - "autoReadDescription": "点击邮件时自动将其标记为已读。" + "autoReadDescription": "点击邮件时自动将其标记为已读。", + "animations": "动画效果", + "animationsDescription": "启用在切换邮件时的平滑动画效果" }, "connections": { "title": "邮箱连接", diff --git a/apps/mail/messages/zh_TW.json b/apps/mail/messages/zh_TW.json index ab47bded00..beb0a2bc7b 100644 --- a/apps/mail/messages/zh_TW.json +++ b/apps/mail/messages/zh_TW.json @@ -452,7 +452,9 @@ "selectDefaultEmail": "選擇預設電子郵件", "defaultEmailDescription": "此電子郵件將作為撰寫新郵件時的預設「寄件者」地址", "autoRead": "自動標記為已讀", - "autoReadDescription": "當您點擊電子郵件時,會自動將其標記為已讀。" + "autoReadDescription": "當您點擊電子郵件時,會自動將其標記為已讀。", + "animations": "動畫效果", + "animationsDescription": "啟用在切換電子郵件時的平滑動畫效果" }, "connections": { "title": "電子郵件連接", diff --git a/i18n.lock b/i18n.lock index 667e049268..83012b0c3e 100644 --- a/i18n.lock +++ b/i18n.lock @@ -893,6 +893,8 @@ checksums: pages/settings/general/defaultEmailDescription: 0ebf26fceccb4cad99f4b2a20cbcfab0 pages/settings/general/autoRead: 959803ec8269e00d8407c6c189269640 pages/settings/general/autoReadDescription: 10de6d8f3040b36a0c4b369172dfe2f7 + pages/settings/general/animations: d2b12468cc840da8ae0ea961fcb5706c + pages/settings/general/animationsDescription: 68d690ede69519e938bcca1fdce79e51 pages/settings/connections/title: d7bc733cc82ab74c649a4816373a2295 pages/settings/connections/description: f05d355994f9fe0e089ce2c54d0de381 pages/settings/connections/disconnectTitle: efecc2354d665e68492fe985af575508 From e1cdeb82c2fc46116a2a29da6acb327fe0dbd64b Mon Sep 17 00:00:00 2001 From: amrit Date: Fri, 1 Aug 2025 20:59:43 +0530 Subject: [PATCH 30/48] feat: add tests using playwright (#1877) 1. Get both the better auth session tokens from appliations/cookies in .env image 2. Enter the email you wish to send to in .env 3. `cd packages/testing` 3. run `npm test:e2e:headed` thats it tbh https://github.com/user-attachments/assets/b703e78c-2373-40a2-b431-f9ba53d5d871 --- ## Summary by cubic Added Playwright end-to-end tests for the mail inbox flow, including authentication setup and email send/reply actions. - **New Features** - Added Playwright config, test scripts, and environment variables for E2E testing. - Implemented tests to sign in, send an email, and reply within the same session. ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Introduced a comprehensive testing package with support for unit, UI, and end-to-end tests. * Added Playwright-based authentication setup and mail inbox end-to-end test scripts. * Provided a dedicated test configuration and TypeScript setup for robust test execution. * **Chores** * Updated environment variable examples to support Playwright testing. * Enhanced main project scripts to facilitate various testing modes. --- .env.example | 7 +- package.json | 4 + packages/testing/e2e/auth.setup.ts | 77 + packages/testing/e2e/mail-inbox.spec.ts | 86 + packages/testing/package.json | 38 + packages/testing/playwright.config.ts | 38 + packages/testing/tsconfig.json | 34 + pnpm-lock.yaml | 1908 ++++++++++++++++++++++- 8 files changed, 2187 insertions(+), 5 deletions(-) create mode 100644 packages/testing/e2e/auth.setup.ts create mode 100644 packages/testing/e2e/mail-inbox.spec.ts create mode 100644 packages/testing/package.json create mode 100644 packages/testing/playwright.config.ts create mode 100644 packages/testing/tsconfig.json diff --git a/.env.example b/.env.example index 8c4f2052ab..a787a645c6 100644 --- a/.env.example +++ b/.env.example @@ -37,4 +37,9 @@ AUTUMN_SECRET_KEY= TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= -TWILIO_PHONE_NUMBER= \ No newline at end of file +TWILIO_PHONE_NUMBER= + +# FOR PLAYWRIGHT E2E TESTING +PLAYWRIGHT_SESSION_TOKEN = +PLAYWRIGHT_SESSION_DATA = +EMAIL = \ No newline at end of file diff --git a/package.json b/package.json index 0cb3a3e64e..d7f330ed5b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "db:studio": "dotenv -- pnpm run -C apps/server db:studio", "sentry:sourcemaps": "sentry-cli sourcemaps inject --org zero-7y --project nextjs ./apps/mail/.next && sentry-cli sourcemaps upload --org zero-7y --project nextjs ./apps/mail/.next", "scripts": "dotenv -- pnpx tsx ./scripts/run.ts", + "test": "pnpm --filter=@zero/testing test", + "test:watch": "pnpm --filter=@zero/testing test:watch", + "test:coverage": "pnpm --filter=@zero/testing test:coverage", + "test:ui": "pnpm --filter=@zero/testing test:ui", "test:ai": "dotenv -- pnpm --filter=@zero/server run test:ai", "eval": "dotenv -- pnpm --filter=@zero/server run eval", "eval:dev": "dotenv -- pnpm --filter=@zero/server run eval:dev", diff --git a/packages/testing/e2e/auth.setup.ts b/packages/testing/e2e/auth.setup.ts new file mode 100644 index 0000000000..deea077b54 --- /dev/null +++ b/packages/testing/e2e/auth.setup.ts @@ -0,0 +1,77 @@ +import { test as setup } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const authFile = path.join(__dirname, '../playwright/.auth/user.json'); + +setup('inject real authentication session', async ({ page }) => { + console.log('Injecting real authentication session...'); + + const SessionToken = process.env.PLAYWRIGHT_SESSION_TOKEN; + const SessionData = process.env.PLAYWRIGHT_SESSION_DATA; + + if (!SessionToken || !SessionData) { + throw new Error('PLAYWRIGHT_SESSION_TOKEN and PLAYWRIGHT_SESSION_DATA environment variables must be set.'); + } + + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60000 }); + + console.log('Page loaded, setting up authentication...'); + + // sets better auth session cookies + await page.context().addCookies([ + { + name: 'better-auth-dev.session_token', + value: SessionToken, + domain: 'localhost', + path: '/', + httpOnly: true, + secure: false, + sameSite: 'Lax' + }, + { + name: 'better-auth-dev.session_data', + value: SessionData, + domain: 'localhost', + path: '/', + httpOnly: true, + secure: false, + sameSite: 'Lax' + } + ]); + + console.log('Real session cookies injected'); + + try { + const decodedSessionData = JSON.parse(atob(SessionData)); + + await page.addInitScript((sessionData) => { + if (sessionData.session) { + localStorage.setItem('better-auth.session', JSON.stringify(sessionData.session.session)); + localStorage.setItem('better-auth.user', JSON.stringify(sessionData.session.user)); + } + }, decodedSessionData); + + console.log('Session data set in localStorage'); + } catch (error) { + console.log('Could not decode session data for localStorage:', error); + } + + await page.goto('/mail/inbox'); + await page.waitForLoadState('domcontentloaded'); + + const currentUrl = page.url(); + console.log('Current URL after clicking Get Started:', currentUrl); + + if (currentUrl.includes('/mail')) { + console.log('Successfully reached mail app! On:', currentUrl); + } else { + console.log('Did not reach mail app. Current URL:', currentUrl); + await page.screenshot({ path: 'debug-auth-failed.png' }); + } + + await page.context().storageState({ path: authFile }); + + console.log('Real authentication session injected and saved!'); +}); diff --git a/packages/testing/e2e/mail-inbox.spec.ts b/packages/testing/e2e/mail-inbox.spec.ts new file mode 100644 index 0000000000..48e3d99cc5 --- /dev/null +++ b/packages/testing/e2e/mail-inbox.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '@playwright/test'; + +const email = process.env.EMAIL; + +if (!email) { + throw new Error('EMAIL environment variable must be set.'); + } + +test.describe('Signing In, Sending mail, Replying to a mail', () => { + test('should send and reply to an email in the same session', async ({ page }) => { + await page.goto('/mail/inbox'); + await page.waitForLoadState('domcontentloaded'); + console.log('Successfully accessed mail inbox'); + + await page.waitForTimeout(2000); + try { + const welcomeModal = page.getByText('Welcome to Zero Email!'); + if (await welcomeModal.isVisible({ timeout: 2000 })) { + console.log('Onboarding modal detected, clicking outside to dismiss...'); + await page.locator('body').click({ position: { x: 100, y: 100 } }); + await page.waitForTimeout(1500); + console.log('Modal successfully dismissed'); + } + } catch { + console.log('No onboarding modal found, proceeding...'); + } + + await expect(page.getByText('Inbox')).toBeVisible(); + console.log('Mail inbox is now visible'); + + console.log('Starting email sending process...'); + await page.getByText('New email').click(); + await page.waitForTimeout(2000); + + await page.locator('input').first().fill(email); + console.log('Filled To: field'); + + await page.getByRole('button', { name: 'Send' }).click(); + console.log('Clicked Send button'); + await page.waitForTimeout(3000); + console.log('Email sent successfully!'); + + console.log('Waiting for email to arrive...'); + await page.waitForTimeout(10000); + + console.log('Looking for the first email in the list...'); + await page.locator('[data-thread-id]').first().click(); + console.log('Clicked on email (PM/AM area).'); + + console.log('Looking for Reply button to confirm email is open...'); + await page.waitForTimeout(2000); + + const replySelectors = [ + 'button:has-text("Reply")', + '[data-testid*="reply"]', + 'button[title*="Reply"]', + 'button:text-is("Reply")', + 'button:text("Reply")' + ]; + + let replyClicked = false; + for (const selector of replySelectors) { + try { + await page.locator(selector).first().click({ force: true }); + console.log(`Clicked Reply button using: ${selector}`); + replyClicked = true; + break; + } catch { + console.log(`Failed to click with ${selector}`); + } + } + + if (!replyClicked) { + console.log('Could not find Reply button'); + } + + await page.waitForTimeout(2000); + + console.log('Sending reply...'); + await page.getByRole('button', { name: 'Send' }).click(); + await page.waitForTimeout(3000); + console.log('Reply sent successfully!'); + + console.log('Entire email flow completed successfully!'); + }); +}); diff --git a/packages/testing/package.json b/packages/testing/package.json new file mode 100644 index 0000000000..28e83d06e7 --- /dev/null +++ b/packages/testing/package.json @@ -0,0 +1,38 @@ +{ + "name": "@zero/testing", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed" + }, + "devDependencies": { + "@cloudflare/playwright": "0.0.11", + "@playwright/test": "^1.40.0", + "@testing-library/jest-dom": "^6.1.4", + "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", + "@types/testing-library__jest-dom": "^6.0.0", + "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^1.0.4", + "@vitest/ui": "^1.0.4", + "happy-dom": "^12.10.3", + "jsdom": "^23.0.1", + "msw": "^2.0.8", + "dotenv": "^16.3.1", + "typescript": "^5.4.0", + "vitest": "^1.0.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "*", + "react-dom": "*" + }, + "dependencies": { + "@tanstack/react-query": "^5.81.5" + } +} \ No newline at end of file diff --git a/packages/testing/playwright.config.ts b/packages/testing/playwright.config.ts new file mode 100644 index 0000000000..d72d0a4e68 --- /dev/null +++ b/packages/testing/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json new file mode 100644 index 0000000000..456b8e25a1 --- /dev/null +++ b/packages/testing/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["vitest/globals", "@testing-library/jest-dom", "node", "@playwright/test"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "react", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["../../apps/mail/*"], + "@zero/server/*": ["../../apps/server/src/*"], + "@zero/mail/*": ["../../apps/mail/*"] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "e2e/**/*.ts" + ], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2684d5e035..ba301b8c9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -714,7 +714,7 @@ importers: version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(happy-dom@12.10.3)(jiti@2.4.2)(jsdom@23.2.0)(msw@2.10.4(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4)(yaml@2.8.0) packages/cli: devDependencies: @@ -755,10 +755,77 @@ importers: specifier: 3.4.17 version: 3.4.17 + packages/testing: + dependencies: + '@tanstack/react-query': + specifier: ^5.81.5 + version: 5.81.5(react@19.1.0) + '@types/react': + specifier: '*' + version: 19.1.6 + '@types/react-dom': + specifier: '*' + version: 19.0.4(@types/react@19.1.6) + react: + specifier: '*' + version: 19.1.0 + react-dom: + specifier: '*' + version: 19.1.0(react@19.1.0) + devDependencies: + '@cloudflare/playwright': + specifier: 0.0.11 + version: 0.0.11 + '@playwright/test': + specifier: ^1.40.0 + version: 1.54.1 + '@testing-library/jest-dom': + specifier: ^6.1.4 + version: 6.6.4 + '@testing-library/react': + specifier: ^14.1.2 + version: 14.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/user-event': + specifier: ^14.5.1 + version: 14.6.1(@testing-library/dom@9.3.4) + '@types/testing-library__jest-dom': + specifier: ^6.0.0 + version: 6.0.0 + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0)) + '@vitest/coverage-v8': + specifier: ^1.0.4 + version: 1.6.1(vitest@1.6.1) + '@vitest/ui': + specifier: ^1.0.4 + version: 1.6.1(vitest@1.6.1) + dotenv: + specifier: ^16.3.1 + version: 16.6.1 + happy-dom: + specifier: ^12.10.3 + version: 12.10.3 + jsdom: + specifier: ^23.0.1 + version: 23.2.0 + msw: + specifier: ^2.0.8 + version: 2.10.4(@types/node@22.15.29)(typescript@5.8.3) + typescript: + specifier: ^5.4.0 + version: 5.8.3 + vitest: + specifier: ^1.0.4 + version: 1.6.1(@types/node@22.15.29)(@vitest/ui@1.6.1)(happy-dom@12.10.3)(jsdom@23.2.0) + packages/tsconfig: {} packages: + '@adobe/css-tools@4.4.3': + resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==} + '@ai-sdk/anthropic@1.2.12': resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==} engines: {node: '>=18'} @@ -826,6 +893,12 @@ packages: '@arcadeai/arcadejs@1.8.1': resolution: {integrity: sha512-ZTj2UvdfFmFn1as4gdDiZD8nbnEFZcZUzH9XtTmjRbgf/1V8s1wEtlzlI3vct+dA+KZ+NhS79AEw5lx/Ki0xSw==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@asamuzakjp/dom-selector@2.0.2': + resolution: {integrity: sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -838,10 +911,18 @@ packages: resolution: {integrity: sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.27.5': resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.28.0': + resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -856,6 +937,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.27.1': resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} engines: {node: '>=6.9.0'} @@ -909,6 +994,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.0': + resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-decorators@7.27.1': resolution: {integrity: sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==} engines: {node: '>=6.9.0'} @@ -939,6 +1029,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.27.1': resolution: {integrity: sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==} engines: {node: '>=6.9.0'} @@ -967,19 +1069,39 @@ packages: resolution: {integrity: sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.0': + resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.27.7': resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.2': + resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} + engines: {node: '>=6.9.0'} + '@barkleapp/css-sanitizer@1.0.0': resolution: {integrity: sha512-22hnMrxMg9BMF8A53LuZ2MEtOwPAziOg9xAoOTcgCSaiB5K9fdbTxUvYkueZki5GSy3PHgIHrPjarqkhhfbiJA==} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@better-auth/utils@0.2.5': resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==} '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@bundled-es-modules/cookie@2.0.1': + resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} + + '@bundled-es-modules/statuses@1.0.1': + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + + '@bundled-es-modules/tough-cookie@0.1.6': + resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@cfcs/core@0.0.6': resolution: {integrity: sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==} @@ -993,6 +1115,9 @@ packages: resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} + '@cloudflare/playwright@0.0.11': + resolution: {integrity: sha512-5meAHlST5K3MKYA7TyL6Ns1XQhYB615qYZxDSn0UnSX14MFgRe+pqlq482dXbgMBappnvvsjZl+Ea3/FQcdbvQ==} + '@cloudflare/unenv-preset@2.3.3': resolution: {integrity: sha512-/M3MEcj3V2WHIRSW1eAQBPRJ6JnGQHc6JKMAPLkDb7pLs3m6X9ES/+K3ceGqxI6TKeF32AWAi7ls0AYzVxCP0A==} peerDependencies: @@ -1048,6 +1173,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.0.2': + resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.0.10': + resolution: {integrity: sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@daybrush/utils@1.13.0': resolution: {integrity: sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==} @@ -1123,6 +1276,12 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} engines: {node: '>=18'} @@ -1141,6 +1300,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.4': resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} engines: {node: '>=18'} @@ -1159,6 +1324,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.4': resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} engines: {node: '>=18'} @@ -1177,6 +1348,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.4': resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} engines: {node: '>=18'} @@ -1195,6 +1372,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.4': resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} engines: {node: '>=18'} @@ -1213,6 +1396,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.4': resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} engines: {node: '>=18'} @@ -1231,6 +1420,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.4': resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} engines: {node: '>=18'} @@ -1249,6 +1444,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.4': resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} engines: {node: '>=18'} @@ -1267,6 +1468,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.4': resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} engines: {node: '>=18'} @@ -1285,6 +1492,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.4': resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} engines: {node: '>=18'} @@ -1303,6 +1516,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.4': resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} engines: {node: '>=18'} @@ -1321,6 +1540,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.4': resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} engines: {node: '>=18'} @@ -1339,6 +1564,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.4': resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} engines: {node: '>=18'} @@ -1357,6 +1588,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.4': resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} engines: {node: '>=18'} @@ -1375,6 +1612,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.4': resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} engines: {node: '>=18'} @@ -1393,6 +1636,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.4': resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} engines: {node: '>=18'} @@ -1411,6 +1660,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.4': resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} engines: {node: '>=18'} @@ -1441,6 +1696,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.4': resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} engines: {node: '>=18'} @@ -1471,6 +1732,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.4': resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} engines: {node: '>=18'} @@ -1489,6 +1756,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.4': resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} engines: {node: '>=18'} @@ -1507,6 +1780,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.4': resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} engines: {node: '>=18'} @@ -1525,6 +1804,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.4': resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} engines: {node: '>=18'} @@ -1543,6 +1828,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.4': resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} engines: {node: '>=18'} @@ -1816,6 +2107,37 @@ packages: resolution: {integrity: sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==} engines: {node: '>=18.0.0'} + '@inquirer/confirm@5.1.14': + resolution: {integrity: sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.1.15': + resolution: {integrity: sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.13': + resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.8': + resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@intercom/messenger-js-sdk@0.0.14': resolution: {integrity: sha512-2dH4BDAh9EI90K7hUkAdZ76W79LM45Sd1OBX7t6Vzy8twpNiQ5X+7sH9G5hlJlkSGnf+vFWlFcy9TOYAyEs1hA==} @@ -1831,6 +2153,17 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1849,6 +2182,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -1901,6 +2237,10 @@ packages: resolution: {integrity: sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w==} engines: {node: '>=18'} + '@mswjs/interceptors@0.39.5': + resolution: {integrity: sha512-B9nHSJYtsv79uo7QdkZ/b/WoKm20IkVSmTc/WCKarmDtFwM0dRx2ouEniqwNkzCSLn3fydzKmnMzjtfdOWt3VQ==} + engines: {node: '>=18'} + '@noble/ciphers@0.6.0': resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} @@ -1935,6 +2275,15 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.57.2': resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} engines: {node: '>=14'} @@ -2182,6 +2531,14 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.54.1': + resolution: {integrity: sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -3508,6 +3865,9 @@ packages: resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==} engines: {node: '>= 10'} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/plugin-replace@6.0.2': resolution: {integrity: sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==} engines: {node: '>=14.0.0'} @@ -3797,6 +4157,9 @@ packages: resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==} engines: {node: '>=20.0.0'} + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sinclair/typebox@0.31.28': resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} @@ -3845,6 +4208,27 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + + '@testing-library/jest-dom@6.6.4': + resolution: {integrity: sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@14.3.1': + resolution: {integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@2.23.0': resolution: {integrity: sha512-Cdfhd0Po1cKMYqHtyv/3XATXpf2Kjo8fuau/QJwrml0NpM18/XX9mAgp2NJ/QaiQ3vi8vDandg7RmZ5OrApglQ==} peerDependencies: @@ -4095,6 +4479,21 @@ packages: '@types/accept-language-parser@1.5.8': resolution: {integrity: sha512-6+dKdh9q/I8xDBnKQKddCBKaWBWLmJ97HTiSbAXVpL7LEgDfOkKF98UVCaZ5KJrtdN5Wa5ndXUiqD3XR9XGqWQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} @@ -4104,6 +4503,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -4215,6 +4617,9 @@ packages: '@types/pluralize@0.0.33': resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/react-dom@18.3.0': + resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + '@types/react-dom@19.0.4': resolution: {integrity: sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==} peerDependencies: @@ -4235,9 +4640,19 @@ packages: '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/testing-library__jest-dom@6.0.0': + resolution: {integrity: sha512-bnreXCgus6IIadyHNlN/oI5FfX4dWgvGhOPvpr7zzCYDGAPIfvyIoAozMBINmhmsVuqV0cncejF2y5KC7ScqOg==} + deprecated: This is a stub types definition. @testing-library/jest-dom provides its own type definitions, so you do not need this installed. + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -4315,6 +4730,20 @@ packages: '@upstash/redis@1.35.0': resolution: {integrity: sha512-WUm0Jz1xN4DBDGeJIi2Y0kVsolWRB2tsVds4SExaiLg4wBdHFMB+8IfZtBWr+BP0FvhuBr5G1/VLrJ9xzIWHsg==} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@1.6.1': + resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} + peerDependencies: + vitest: 1.6.1 + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -4335,18 +4764,35 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/ui@1.6.1': + resolution: {integrity: sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==} + peerDependencies: + vitest: 1.6.1 + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} @@ -4444,6 +4890,10 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4456,6 +4906,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -4477,6 +4931,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -4523,6 +4984,9 @@ packages: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4592,6 +5056,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.0: resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} @@ -4685,6 +5152,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + chai@5.2.1: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} @@ -4709,6 +5180,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -4740,6 +5214,14 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cloudflare@4.5.0: resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} @@ -4841,6 +5323,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -4935,15 +5420,26 @@ packages: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -4994,6 +5490,10 @@ packages: data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -5037,6 +5537,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -5060,10 +5563,18 @@ packages: babel-plugin-macros: optional: true + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -5117,6 +5628,10 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -5124,6 +5639,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -5356,6 +5877,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-iterator-helpers@1.2.1: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} @@ -5394,6 +5918,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -5551,6 +6080,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -5648,6 +6181,9 @@ packages: fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fft.js@4.0.4: resolution: {integrity: sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==} @@ -5755,6 +6291,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5785,6 +6326,13 @@ packages: gesto@1.19.4: resolution: {integrity: sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -5808,6 +6356,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -5847,6 +6399,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} @@ -5888,10 +6444,17 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} + happy-dom@12.10.3: + resolution: {integrity: sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -5933,6 +6496,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -5971,9 +6537,16 @@ packages: resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -5991,6 +6564,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -6007,6 +6584,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -6050,6 +6631,14 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -6097,6 +6686,10 @@ packages: is-any-array@2.0.1: resolution: {integrity: sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -6177,6 +6770,9 @@ packages: resolution: {integrity: sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==} engines: {node: '>=16'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -6193,6 +6789,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -6212,6 +6811,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -6257,6 +6860,22 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -6326,6 +6945,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@23.2.0: + resolution: {integrity: sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -6453,6 +7081,10 @@ packages: linkifyjs@4.3.1: resolution: {integrity: sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -6500,6 +7132,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.4: resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} @@ -6536,6 +7171,13 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-it-task-lists@2.1.1: resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} @@ -6584,6 +7226,9 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -6697,10 +7342,18 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@4.20250617.4: resolution: {integrity: sha512-IAoApFKxOJlaaFkym5ETstVX3qWzVt3xyqCDj6vSSTgEH3zxZJ5417jZGg8iQfMHosKCcQH1doPPqqnOZm/yrw==} engines: {node: '>=18.0.0'} @@ -6760,6 +7413,9 @@ packages: ml-xsadd@3.0.1: resolution: {integrity: sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg==} + mlly@1.7.4: + resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -6783,13 +7439,31 @@ packages: react-dom: optional: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.10.4: + resolution: {integrity: sha512-6R1or/qyele7q3RyPwNuvc0IxO8L8/Aim6Sz5ncXEgcWUNxSKE+udriTOWHtpMwmfkLYlacA2y7TIx4cL5lgHA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -6888,6 +7562,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -6921,6 +7599,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -6963,6 +7645,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + openai@4.104.0: resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} hasBin: true @@ -6982,6 +7668,9 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + overlap-area@1.1.0: resolution: {integrity: sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==} @@ -6998,6 +7687,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -7064,10 +7757,18 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -7092,6 +7793,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -7147,6 +7851,19 @@ packages: resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + playwright-core@1.54.1: + resolution: {integrity: sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.54.1: + resolution: {integrity: sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -7313,6 +8030,14 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -7427,6 +8152,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -7452,6 +8180,9 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7541,6 +8272,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -7566,6 +8300,10 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -7710,6 +8448,10 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -7731,6 +8473,10 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -7739,6 +8485,9 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resend@4.1.2: resolution: {integrity: sha512-km0btrAj/BqIaRlS+SoLNMaCAUUWEgcEvZpycfVvoXEwAHCxU+vp/ikxPgKRkyKyiR2iDcdUq5uIBTDK9oSSSQ==} engines: {node: '>=18'} @@ -7803,6 +8552,12 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rtl-css-js@1.16.1: resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} @@ -7844,6 +8599,10 @@ packages: resolution: {integrity: sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA==} engines: {node: '>=16'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -7961,6 +8720,10 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -8056,6 +8819,9 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-collapse-leading-whitespace@7.0.9: resolution: {integrity: sha512-lEuTHlogBT9PWipfk0FOyvoMKX8syiE03QoFk5MDh8oS0AJ2C07IlstR5cGkxz48nKkOIuvkC28w9Rx/cVRNDg==} engines: {node: '>=14.18.0'} @@ -8127,6 +8893,14 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -8135,6 +8909,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} @@ -8173,6 +8950,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -8206,6 +8986,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -8243,6 +9027,10 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8255,6 +9043,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.3: resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} @@ -8292,9 +9084,21 @@ packages: resolution: {integrity: sha512-IKJ6EzuPPWtKtEIEPpIdXv9j5j2LGJEYk0CKY2efgKoYKLBiZdh6iQkLVBow/CB3phyWAWCyk+bZeaimJn6uRQ==} engines: {node: '>=14.16'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -8385,6 +9189,14 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -8483,6 +9295,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -8506,6 +9322,9 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url-template@2.0.8: resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} @@ -8612,6 +9431,11 @@ packages: vue: optional: true + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -8636,6 +9460,37 @@ packages: vite: optional: true + vite@5.4.19: + resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -8676,6 +9531,31 @@ packages: yaml: optional: true + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -8707,6 +9587,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -8717,6 +9601,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -8727,14 +9615,26 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8791,6 +9691,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8814,14 +9718,25 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder@13.0.2: resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} engines: {node: '>=6.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -8830,10 +9745,26 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + yoga-wasm-web@0.3.3: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} @@ -8881,6 +9812,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.3': {} + '@ai-sdk/anthropic@1.2.12(zod@3.25.67)': dependencies: '@ai-sdk/provider': 1.1.3 @@ -8959,6 +9892,20 @@ snapshots: transitivePeerDependencies: - encoding + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@asamuzakjp/dom-selector@2.0.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 2.3.1 + is-potential-custom-element-name: 1.0.1 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -8987,6 +9934,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) + '@babel/helpers': 7.27.6 + '@babel/parser': 7.28.0 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.2 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.27.5': dependencies: '@babel/parser': 7.27.7 @@ -8995,6 +9962,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 + '@babel/generator@7.28.0': + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.2 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 + jsesc: 3.0.2 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.27.7 @@ -9020,6 +9995,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.27.7 @@ -9043,6 +10020,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.27.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.27.7 @@ -9080,6 +10066,10 @@ snapshots: dependencies: '@babel/types': 7.27.7 + '@babel/parser@7.28.0': + dependencies: + '@babel/types': 7.28.2 + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.27.7)': dependencies: '@babel/core': 7.27.7 @@ -9111,6 +10101,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typescript@7.27.1(@babel/core@7.27.7)': dependencies: '@babel/core': 7.27.7 @@ -9157,13 +10157,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.28.0': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.0 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.0 + '@babel/template': 7.27.2 + '@babel/types': 7.28.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + '@babel/types@7.27.7': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.2': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@barkleapp/css-sanitizer@1.0.0': {} + '@bcoe/v8-coverage@0.2.3': {} + '@better-auth/utils@0.2.5': dependencies: typescript: 5.8.3 @@ -9171,6 +10190,19 @@ snapshots: '@better-fetch/fetch@1.1.18': {} + '@bundled-es-modules/cookie@2.0.1': + dependencies: + cookie: 0.7.2 + + '@bundled-es-modules/statuses@1.0.1': + dependencies: + statuses: 2.0.2 + + '@bundled-es-modules/tough-cookie@0.1.6': + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + '@cfcs/core@0.0.6': dependencies: '@egjs/component': 3.0.5 @@ -9190,6 +10222,8 @@ snapshots: dependencies: mime: 3.0.0 + '@cloudflare/playwright@0.0.11': {} + '@cloudflare/unenv-preset@2.3.3(unenv@2.0.0-rc.17)(workerd@1.20250617.0)': dependencies: unenv: 2.0.0-rc.17 @@ -9240,6 +10274,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.0.2': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.0.2 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@daybrush/utils@1.13.0': {} '@dnd-kit/accessibility@3.1.1(react@19.1.0)': @@ -9318,6 +10372,9 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.10.1 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.4': optional: true @@ -9327,6 +10384,9 @@ snapshots: '@esbuild/android-arm64@0.18.20': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.4': optional: true @@ -9336,6 +10396,9 @@ snapshots: '@esbuild/android-arm@0.18.20': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.4': optional: true @@ -9345,6 +10408,9 @@ snapshots: '@esbuild/android-x64@0.18.20': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.4': optional: true @@ -9354,6 +10420,9 @@ snapshots: '@esbuild/darwin-arm64@0.18.20': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.4': optional: true @@ -9363,6 +10432,9 @@ snapshots: '@esbuild/darwin-x64@0.18.20': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.4': optional: true @@ -9372,6 +10444,9 @@ snapshots: '@esbuild/freebsd-arm64@0.18.20': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.4': optional: true @@ -9381,6 +10456,9 @@ snapshots: '@esbuild/freebsd-x64@0.18.20': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.4': optional: true @@ -9390,6 +10468,9 @@ snapshots: '@esbuild/linux-arm64@0.18.20': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.4': optional: true @@ -9399,6 +10480,9 @@ snapshots: '@esbuild/linux-arm@0.18.20': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.4': optional: true @@ -9408,6 +10492,9 @@ snapshots: '@esbuild/linux-ia32@0.18.20': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.4': optional: true @@ -9417,6 +10504,9 @@ snapshots: '@esbuild/linux-loong64@0.18.20': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.4': optional: true @@ -9426,6 +10516,9 @@ snapshots: '@esbuild/linux-mips64el@0.18.20': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.4': optional: true @@ -9435,6 +10528,9 @@ snapshots: '@esbuild/linux-ppc64@0.18.20': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.4': optional: true @@ -9444,6 +10540,9 @@ snapshots: '@esbuild/linux-riscv64@0.18.20': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.4': optional: true @@ -9453,6 +10552,9 @@ snapshots: '@esbuild/linux-s390x@0.18.20': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.4': optional: true @@ -9462,6 +10564,9 @@ snapshots: '@esbuild/linux-x64@0.18.20': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.4': optional: true @@ -9477,6 +10582,9 @@ snapshots: '@esbuild/netbsd-x64@0.18.20': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.4': optional: true @@ -9492,6 +10600,9 @@ snapshots: '@esbuild/openbsd-x64@0.18.20': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.4': optional: true @@ -9501,6 +10612,9 @@ snapshots: '@esbuild/sunos-x64@0.18.20': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.4': optional: true @@ -9510,6 +10624,9 @@ snapshots: '@esbuild/win32-arm64@0.18.20': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.4': optional: true @@ -9519,6 +10636,9 @@ snapshots: '@esbuild/win32-ia32@0.18.20': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.4': optional: true @@ -9528,6 +10648,9 @@ snapshots: '@esbuild/win32-x64@0.18.20': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.4': optional: true @@ -9803,6 +10926,32 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros + '@inquirer/confirm@5.1.14(@types/node@22.15.29)': + dependencies: + '@inquirer/core': 10.1.15(@types/node@22.15.29) + '@inquirer/type': 3.0.8(@types/node@22.15.29) + optionalDependencies: + '@types/node': 22.15.29 + + '@inquirer/core@10.1.15(@types/node@22.15.29)': + dependencies: + '@inquirer/figures': 1.0.13 + '@inquirer/type': 3.0.8(@types/node@22.15.29) + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.15.29 + + '@inquirer/figures@1.0.13': {} + + '@inquirer/type@3.0.8(@types/node@22.15.29)': + optionalDependencies: + '@types/node': 22.15.29 + '@intercom/messenger-js-sdk@0.0.14': {} '@isaacs/balanced-match@4.0.1': {} @@ -9820,6 +10969,17 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/schema@0.1.3': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -9837,6 +10997,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.29': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -9904,6 +11069,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@mswjs/interceptors@0.39.5': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@noble/ciphers@0.6.0': {} '@noble/hashes@1.8.0': {} @@ -9951,6 +11125,15 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@opentelemetry/api-logs@0.57.2': dependencies: '@opentelemetry/api': 1.9.0 @@ -10253,6 +11436,12 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.54.1': + dependencies: + playwright: 1.54.1 + + '@polka/url@1.0.0-next.29': {} + '@popperjs/core@2.11.8': {} '@prisma/instrumentation@6.11.1(@opentelemetry/api@1.9.0)': @@ -11703,6 +12892,8 @@ snapshots: '@resvg/resvg-wasm@2.6.2': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/plugin-replace@6.0.2(rollup@4.44.1)': dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.44.1) @@ -12011,6 +13202,8 @@ snapshots: '@peculiar/asn1-schema': 2.3.15 '@peculiar/asn1-x509': 2.3.15 + '@sinclair/typebox@0.27.8': {} + '@sinclair/typebox@0.31.28': {} '@sqlite.org/sqlite-wasm@3.48.0-build4': {} @@ -12059,6 +13252,39 @@ snapshots: '@tanstack/query-core': 5.81.5 react: 19.1.0 + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.27.6 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.4': + dependencies: + '@adobe/css-tools': 4.4.3 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@14.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.6 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.3.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@testing-library/user-event@14.6.1(@testing-library/dom@9.3.4)': + dependencies: + '@testing-library/dom': 9.3.4 + '@tiptap/core@2.23.0(@tiptap/pm@2.23.0)': dependencies: '@tiptap/pm': 2.23.0 @@ -12318,6 +13544,29 @@ snapshots: '@types/accept-language-parser@1.5.8': {} + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.7 + '@types/canvas-confetti@1.9.0': {} '@types/chai@5.2.2': @@ -12328,6 +13577,8 @@ snapshots: dependencies: '@types/node': 22.15.29 + '@types/cookie@0.6.0': {} + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -12441,10 +13692,18 @@ snapshots: '@types/pluralize@0.0.33': {} + '@types/react-dom@18.3.0': + dependencies: + '@types/react': 19.1.6 + '@types/react-dom@19.0.4(@types/react@19.0.10)': dependencies: '@types/react': 19.0.10 + '@types/react-dom@19.0.4(@types/react@19.1.6)': + dependencies: + '@types/react': 19.1.6 + '@types/react@19.0.10': dependencies: csstype: 3.1.3 @@ -12461,10 +13720,18 @@ snapshots: '@types/shimmer@1.2.0': {} + '@types/statuses@2.0.6': {} + '@types/tedious@4.0.14': dependencies: '@types/node': 22.15.29 + '@types/testing-library__jest-dom@6.0.0': + dependencies: + '@testing-library/jest-dom': 6.6.4 + + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -12568,6 +13835,43 @@ snapshots: dependencies: uncrypto: 0.1.3 + '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0))': + dependencies: + '@babel/core': 7.28.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@1.6.1(vitest@1.6.1)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + picocolors: 1.1.1 + std-env: 3.9.0 + strip-literal: 2.1.1 + test-exclude: 6.0.0 + vitest: 1.6.1(@types/node@22.15.29)(@vitest/ui@1.6.1)(happy-dom@12.10.3)(jsdom@23.2.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -12576,12 +13880,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@22.15.29)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: + msw: 2.10.4(@types/node@22.15.29)(typescript@5.8.3) vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) '@vitest/pretty-format@2.1.9': @@ -12592,6 +13897,12 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -12603,16 +13914,44 @@ snapshots: pathe: 2.0.3 strip-literal: 3.0.0 + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.17 + pathe: 1.1.2 + pretty-format: 29.7.0 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.3 + '@vitest/ui@1.6.1(vitest@1.6.1)': + dependencies: + '@vitest/utils': 1.6.1 + fast-glob: 3.3.3 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 1.1.2 + picocolors: 1.1.1 + sirv: 2.0.4 + vitest: 1.6.1(@types/node@22.15.29)(@vitest/ui@1.6.1)(happy-dom@12.10.3)(jsdom@23.2.0) + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + '@vitest/utils@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -12727,6 +14066,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -12735,6 +14078,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -12752,6 +14097,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -12833,6 +14184,8 @@ snapshots: pvutils: 1.1.3 tslib: 2.8.1 + assertion-error@1.1.0: {} + assertion-error@2.0.1: {} astral-regex@2.0.0: {} @@ -12930,6 +14283,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bignumber.js@9.3.0: {} binary-extensions@2.3.0: {} @@ -13031,6 +14388,16 @@ snapshots: ccount@2.0.1: {} + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + chai@5.2.1: dependencies: assertion-error: 2.0.1 @@ -13054,6 +14421,10 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + check-error@2.1.1: {} cheerio-select@2.1.0: @@ -13105,6 +14476,14 @@ snapshots: dependencies: clsx: 2.1.1 + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cloudflare@4.5.0: dependencies: '@types/node': 18.19.115 @@ -13227,6 +14606,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -13319,10 +14700,22 @@ snapshots: mdn-data: 2.0.14 source-map: 0.6.1 + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + css-what@6.1.0: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.1.3: {} d3-array@3.2.4: @@ -13365,6 +14758,11 @@ snapshots: data-uri-to-buffer@2.0.2: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -13401,6 +14799,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -13413,8 +14813,33 @@ snapshots: dedent@1.6.0: {} + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + deep-eql@5.0.2: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -13455,12 +14880,18 @@ snapshots: diff-match-patch@1.0.5: {} + diff-sequences@29.6.3: {} + dlv@1.1.3: {} doctrine@2.1.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.27.6 @@ -13684,6 +15115,18 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + es-iterator-helpers@1.2.1: dependencies: call-bind: 1.0.8 @@ -13760,6 +15203,32 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -14026,6 +15495,18 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + exit-hook@2.2.1: {} expand-template@2.0.3: {} @@ -14149,6 +15630,8 @@ snapshots: fflate@0.7.4: {} + fflate@0.8.2: {} + fft.js@4.0.4: {} file-entry-cache@8.0.0: @@ -14248,6 +15731,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -14291,6 +15777,10 @@ snapshots: '@daybrush/utils': 1.13.0 '@scena/event-emitter': 1.0.5 + get-caller-file@2.0.5: {} + + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -14320,6 +15810,8 @@ snapshots: get-stream@6.0.1: {} + get-stream@8.0.1: {} + get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -14374,6 +15866,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + glob@9.3.5: dependencies: fs.realpath: 1.0.0 @@ -14424,6 +15925,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.11.0: {} + gtoken@7.1.0: dependencies: gaxios: 6.7.1 @@ -14432,6 +15935,15 @@ snapshots: - encoding - supports-color + happy-dom@12.10.3: + dependencies: + css.escape: 1.5.1 + entities: 4.5.0 + iconv-lite: 0.6.3 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -14482,6 +15994,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -14513,8 +16027,14 @@ snapshots: dependencies: lru-cache: 7.18.3 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-entities@2.6.0: {} + html-escaper@2.0.2: {} + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -14547,6 +16067,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -14565,6 +16092,8 @@ snapshots: human-signals@2.1.0: {} + human-signals@5.0.0: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -14601,6 +16130,13 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -14639,6 +16175,11 @@ snapshots: is-any-array@2.0.1: {} + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -14716,6 +16257,8 @@ snapshots: is-network-error@1.1.0: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -14727,6 +16270,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -14744,6 +16289,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@3.0.0: {} + is-stream@4.0.1: {} is-string@1.1.1: @@ -14782,6 +16329,27 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -14840,6 +16408,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@23.2.0: + dependencies: + '@asamuzakjp/dom-selector': 2.0.2 + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.3 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 7.3.0 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.0.2: {} json-bigint@1.0.0: @@ -14987,6 +16583,11 @@ snapshots: linkifyjs@4.3.1: {} + local-pkg@0.5.1: + dependencies: + mlly: 1.7.4 + pkg-types: 1.3.1 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -15021,6 +16622,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + loupe@3.1.4: {} lowlight@3.3.0: @@ -15053,6 +16658,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + markdown-it-task-lists@2.1.1: {} markdown-it@14.1.0: @@ -15164,6 +16779,8 @@ snapshots: mdn-data@2.0.14: {} + mdn-data@2.0.30: {} + mdurl@2.0.0: {} media-typer@1.1.0: {} @@ -15335,8 +16952,12 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} + mimic-response@3.1.0: {} + min-indent@1.0.1: {} + miniflare@4.20250617.4: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -15415,6 +17036,13 @@ snapshots: ml-xsadd@3.0.1: {} + mlly@1.7.4: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + module-details-from-path@1.0.4: {} motion-dom@12.19.0: @@ -15431,10 +17059,39 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + mrmime@2.0.1: {} + ms@2.1.3: {} + msw@2.10.4(@types/node@22.15.29)(typescript@5.8.3): + dependencies: + '@bundled-es-modules/cookie': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 5.1.14(@types/node@22.15.29) + '@mswjs/interceptors': 0.39.5 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.6 + graphql: 16.11.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + strict-event-emitter: 0.5.1 + type-fest: 4.41.0 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + mustache@4.2.0: {} + mute-stream@2.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -15562,6 +17219,10 @@ snapshots: dependencies: path-key: 3.1.1 + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -15579,6 +17240,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.7: @@ -15633,6 +17299,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + openai@4.104.0(ws@8.18.0)(zod@3.25.67): dependencies: '@types/node': 18.19.115 @@ -15659,6 +17329,8 @@ snapshots: orderedmap@2.1.1: {} + outvariant@1.4.3: {} + overlap-area@1.1.0: dependencies: '@daybrush/utils': 1.13.0 @@ -15684,6 +17356,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -15759,8 +17435,12 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -15781,6 +17461,8 @@ snapshots: pathe@2.0.3: {} + pathval@1.1.1: {} + pathval@2.0.1: {} peberminta@0.9.0: {} @@ -15831,6 +17513,20 @@ snapshots: pkce-challenge@5.0.0: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.7.4 + pathe: 2.0.3 + + playwright-core@1.54.1: {} + + playwright@1.54.1: + dependencies: + playwright-core: 1.54.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} @@ -15930,6 +17626,18 @@ snapshots: prettier@3.5.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + printable-characters@1.0.42: {} prismjs@1.30.0: {} @@ -16073,6 +17781,10 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -16094,6 +17806,8 @@ snapshots: dependencies: side-channel: 1.1.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -16234,6 +17948,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-markdown@10.1.0(@types/react@19.0.10)(react@19.1.0): @@ -16294,6 +18010,8 @@ snapshots: react-refresh@0.14.2: {} + react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.0.10)(react@19.1.0): dependencies: react: 19.1.0 @@ -16468,6 +18186,11 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -16511,6 +18234,8 @@ snapshots: repeat-string@1.6.1: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} require-in-the-middle@7.5.2: @@ -16521,6 +18246,8 @@ snapshots: transitivePeerDependencies: - supports-color + requires-port@1.0.0: {} + resend@4.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@react-email/render': 1.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -16605,6 +18332,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.6.0: {} + + rrweb-cssom@0.8.0: {} + rtl-css-js@1.16.1: dependencies: '@babel/runtime': 7.27.6 @@ -16666,6 +18397,10 @@ snapshots: postcss-value-parser: 4.2.0 yoga-wasm-web: 0.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.26.0: {} scmp@2.1.0: {} @@ -16834,6 +18569,12 @@ snapshots: dependencies: is-arrayish: 0.3.2 + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} slice-ansi@4.0.0: @@ -16924,6 +18665,8 @@ snapshots: stream-shift@1.0.3: {} + strict-event-emitter@0.5.1: {} + string-collapse-leading-whitespace@7.0.9: {} string-left-right@6.0.20: @@ -17026,10 +18769,20 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 @@ -17075,6 +18828,8 @@ snapshots: react: 19.1.0 use-sync-external-store: 1.5.0(react@19.1.0) + symbol-tree@3.2.4: {} + table@6.9.0: dependencies: ajv: 8.17.1 @@ -17137,6 +18892,12 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -17171,12 +18932,16 @@ snapshots: fdir: 6.4.6(picomatch@4.0.2) picomatch: 4.0.2 + tinypool@0.8.4: {} + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} tinyrainbow@2.0.0: {} + tinyspy@2.2.1: {} + tinyspy@4.0.3: {} tippy.js@6.3.7: @@ -17210,8 +18975,21 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + totalist@3.0.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -17302,6 +19080,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.1.0: {} + + type-fest@0.21.3: {} + type-fest@4.41.0: {} type-is@2.0.1: @@ -17430,6 +19212,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + universalify@0.2.0: {} + unpipe@1.0.0: {} unplugin@1.0.1: @@ -17457,6 +19241,11 @@ snapshots: url-join@4.0.1: {} + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + url-template@2.0.8: {} urlpattern-polyfill@10.1.0: {} @@ -17546,6 +19335,24 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + vite-node@1.6.1(@types/node@22.15.29): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.19(@types/node@22.15.29) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@22.13.8)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0): dependencies: cac: 6.7.14 @@ -17610,6 +19417,15 @@ snapshots: - supports-color - typescript + vite@5.4.19(@types/node@22.15.29): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.3 + rollup: 4.44.1 + optionalDependencies: + '@types/node': 22.15.29 + fsevents: 2.3.3 + vite@6.3.5(@types/node@22.13.8)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0): dependencies: esbuild: 0.25.5 @@ -17640,11 +19456,48 @@ snapshots: tsx: 4.19.4 yaml: 2.8.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0): + vitest@1.6.1(@types/node@22.15.29)(@vitest/ui@1.6.1)(happy-dom@12.10.3)(jsdom@23.2.0): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.2 + chai: 4.5.0 + debug: 4.4.1 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.17 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.9.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.19(@types/node@22.15.29) + vite-node: 1.6.1(@types/node@22.15.29) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.29 + '@vitest/ui': 1.6.1(vitest@1.6.1) + happy-dom: 12.10.3 + jsdom: 23.2.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(happy-dom@12.10.3)(jiti@2.4.2)(jsdom@23.2.0)(msw@2.10.4(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@22.15.29)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -17668,6 +19521,8 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.15.29 + happy-dom: 12.10.3 + jsdom: 23.2.0 transitivePeerDependencies: - jiti - less @@ -17684,24 +19539,41 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@4.0.0-beta.3: {} web-vitals@4.2.4: {} webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webpack-sources@3.3.3: {} webpack-virtual-modules@0.5.0: {} webpack-virtual-modules@0.6.2: {} + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -17795,6 +19667,12 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -17811,16 +19689,38 @@ snapshots: ws@8.18.0: {} + xml-name-validator@5.0.0: {} + xmlbuilder@13.0.2: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.8.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} + yocto-queue@1.2.1: {} + + yoctocolors-cjs@2.1.2: {} + yoga-wasm-web@0.3.3: {} youch@3.3.4: From b709dfcba4cf1f5d72c273bf4724bb15ee1b387b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:26:07 -0700 Subject: [PATCH 31/48] Centralize env imports from cloudflare:workers (#1792) # Centralize env imports from cloudflare:workers ## Summary This PR centralizes all `env` imports from `cloudflare:workers` in the `apps/server` directory by creating a single centralized file (`src/env.ts`) that exports both the runtime `env` and its type. All 19 files that previously imported `env` directly from `cloudflare:workers` have been updated to use the centralized import. **Key Changes:** - Created `apps/server/src/env.ts` to export `env` and `Env` type centrally - Updated 19 files across the server codebase to import from centralized location - Preserved type safety by exporting `Env` type for use in type definitions - Maintained all existing functionality while improving maintainability This change makes env-related maintenance easier and ensures consistency across the codebase. All `env` bindings are now imported from a single source of truth. ## Review & Testing Checklist for Human - [ ] **Verify import paths are correct** - Spot check 3-4 files with different relative paths (e.g., `src/main.ts` uses `./env`, `src/lib/utils.ts` uses `../env`, `src/trpc/routes/ai/compose.ts` uses `../../../env`) to ensure no typos in path calculations - [ ] **Test core env-dependent functionality** - Beyond server startup, verify that authentication, AI services, and email processing work correctly since these heavily depend on env bindings - [ ] **Confirm build works properly** - Run the full build process to catch any import resolution issues that might not surface during dev server startup - [ ] **Verify type safety** - Check that TypeScript compilation passes and that the `Env` type export works correctly in `ctx.ts` and other type definitions **Recommended Test Plan:** 1. Start the dev server and confirm it runs without errors 2. Test a few core user flows that depend on env variables (login, AI features, email sync) 3. Run any available test suites to catch regressions 4. Build the project for production to verify no import issues --- ### Diagram ```mermaid %%{ init : { "theme" : "default" }}%% graph TD env["apps/server/src/env.ts
(NEW CENTRALIZED FILE)"]:::major-edit main["apps/server/src/main.ts"]:::major-edit ctx["apps/server/src/ctx.ts"]:::major-edit services["apps/server/src/lib/services.ts"]:::minor-edit utils["apps/server/src/lib/utils.ts"]:::minor-edit auth["apps/server/src/lib/auth.ts"]:::minor-edit google["apps/server/src/lib/driver/google.ts"]:::minor-edit compose["apps/server/src/trpc/routes/ai/compose.ts"]:::minor-edit cloudflare["'cloudflare:workers'
(External Module)"]:::context env --> main env --> ctx env --> services env --> utils env --> auth env --> google env --> compose cloudflare --> env cloudflare --> main main -.->|"Also imports WorkerEntrypoint,
DurableObject, RpcTarget"| cloudflare subgraph Legend L1["Major Edit"]:::major-edit L2["Minor Edit"]:::minor-edit L3["Context/No Edit"]:::context end classDef major-edit fill:#90EE90 classDef minor-edit fill:#87CEEB classDef context fill:#FFFFFF ``` ### Notes - The centralized `env.ts` file exports both the runtime `env` and its TypeScript type for maximum flexibility - Some files like `main.ts` and `routes/chat.ts` still import other items from `cloudflare:workers` (WorkerEntrypoint, DurableObject, RpcTarget) - only the `env` import was moved - Server startup testing was successful, confirming basic functionality works - This change improves maintainability by providing a single point of control for env imports **Session Info:** Requested by Adam (@MrgSub) **Link to Devin run:** https://app.devin.ai/sessions/d31157d47fdb432c961bf8fae7248dd1 --- ## Summary by cubic Centralized all env imports from cloudflare:workers into a single src/env.ts file in apps/server, updating 19 files to use this shared import for better maintainability and type safety. - **Refactors** - Created src/env.ts to export env and its type. - Updated all server files to import env from the new centralized file. --- apps/server/src/ctx.ts | 4 ++-- apps/server/src/env.ts | 4 ++++ apps/server/src/lib/auth.ts | 2 +- apps/server/src/lib/brain.ts | 2 +- apps/server/src/lib/driver/google.ts | 2 +- apps/server/src/lib/factories/base-subscription.factory.ts | 2 +- apps/server/src/lib/factories/google-subscription.factory.ts | 2 +- apps/server/src/lib/server-utils.ts | 2 +- apps/server/src/lib/services.ts | 2 +- apps/server/src/lib/utils.ts | 2 +- apps/server/src/main.ts | 3 ++- apps/server/src/pipelines.effect.ts | 3 ++- apps/server/src/routes/agent/index.ts | 2 +- apps/server/src/routes/agent/tools.ts | 2 +- apps/server/src/routes/ai.ts | 2 +- apps/server/src/routes/autumn.ts | 2 +- apps/server/src/services/writing-style-service.ts | 2 +- apps/server/src/trpc/routes/ai/compose.ts | 2 +- apps/server/src/trpc/routes/ai/search.ts | 2 +- apps/server/src/trpc/routes/brain.ts | 2 +- pnpm-lock.yaml | 4 ++-- 21 files changed, 28 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/env.ts diff --git a/apps/server/src/ctx.ts b/apps/server/src/ctx.ts index b599a8d3a8..d1144f18e9 100644 --- a/apps/server/src/ctx.ts +++ b/apps/server/src/ctx.ts @@ -1,4 +1,4 @@ -import type { env } from 'cloudflare:workers'; +import type { Env } from './env'; import type { Autumn } from 'autumn-js'; import type { Auth } from './lib/auth'; @@ -10,4 +10,4 @@ export type HonoVariables = { autumn: Autumn; }; -export type HonoContext = { Variables: HonoVariables; Bindings: typeof env }; +export type HonoContext = { Variables: HonoVariables; Bindings: Env }; diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100644 index 0000000000..c1d22047cf --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,4 @@ +import { env } from 'cloudflare:workers'; + +export { env }; +export type Env = typeof env; diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index f4c2dad423..4a560b3bd6 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -21,7 +21,7 @@ import { APIError } from 'better-auth/api'; import { getZeroDB } from './server-utils'; import { type EProviders } from '../types'; import type { HonoContext } from '../ctx'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; import { createDriver } from './driver'; import { createDb } from '../db'; import { Effect } from 'effect'; diff --git a/apps/server/src/lib/brain.ts b/apps/server/src/lib/brain.ts index a73bce547c..a5f367efa1 100644 --- a/apps/server/src/lib/brain.ts +++ b/apps/server/src/lib/brain.ts @@ -4,7 +4,7 @@ import { AiChatPrompt, StyledEmailAssistantSystemPrompt } from './prompts'; import { resetConnection } from './server-utils'; import { EPrompts, EProviders } from '../types'; import { getPromptName } from '../pipelines'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; export const enableBrainFunction = async (connection: { id: string; providerId: EProviders }) => { try { diff --git a/apps/server/src/lib/driver/google.ts b/apps/server/src/lib/driver/google.ts index 87c3be0a22..9d9a285e25 100644 --- a/apps/server/src/lib/driver/google.ts +++ b/apps/server/src/lib/driver/google.ts @@ -19,7 +19,7 @@ import type { CreateDraftData } from '../schemas'; import { createMimeMessage } from 'mimetext'; import { people } from '@googleapis/people'; import { cleanSearchValue } from '../utils'; -import { env } from 'cloudflare:workers'; +import { env } from '../../env'; import { Effect } from 'effect'; import * as he from 'he'; diff --git a/apps/server/src/lib/factories/base-subscription.factory.ts b/apps/server/src/lib/factories/base-subscription.factory.ts index 127e962750..0c4f755a21 100644 --- a/apps/server/src/lib/factories/base-subscription.factory.ts +++ b/apps/server/src/lib/factories/base-subscription.factory.ts @@ -3,7 +3,7 @@ import { defaultLabels, EProviders, } from '../../types'; import { connection } from '../../db/schema'; -import { env } from 'cloudflare:workers'; +import { env } from '../../env'; import { createDb } from '../../db'; import { eq } from 'drizzle-orm'; diff --git a/apps/server/src/lib/factories/google-subscription.factory.ts b/apps/server/src/lib/factories/google-subscription.factory.ts index b231fae89d..659480f171 100644 --- a/apps/server/src/lib/factories/google-subscription.factory.ts +++ b/apps/server/src/lib/factories/google-subscription.factory.ts @@ -2,8 +2,8 @@ import { BaseSubscriptionFactory, type SubscriptionData } from './base-subscript import { c, getNotificationsUrl } from '../../lib/utils'; import { resetConnection } from '../server-utils'; import jwt from '@tsndr/cloudflare-worker-jwt'; +import { env } from '../../env'; import { connection } from '../../db/schema'; -import { env } from 'cloudflare:workers'; import { EProviders } from '../../types'; interface GoogleServiceAccount { diff --git a/apps/server/src/lib/server-utils.ts b/apps/server/src/lib/server-utils.ts index 7bc044cb13..ea0fb66314 100644 --- a/apps/server/src/lib/server-utils.ts +++ b/apps/server/src/lib/server-utils.ts @@ -1,7 +1,7 @@ import { getContext } from 'hono/context-storage'; import { connection } from '../db/schema'; import type { HonoContext } from '../ctx'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; import { createDriver } from './driver'; import { eq } from 'drizzle-orm'; import { createDb } from '../db'; diff --git a/apps/server/src/lib/services.ts b/apps/server/src/lib/services.ts index 6fbabcf1cd..50590ce46b 100644 --- a/apps/server/src/lib/services.ts +++ b/apps/server/src/lib/services.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:workers'; +import { env } from '../env'; import { Redis } from '@upstash/redis'; import { Resend } from 'resend'; diff --git a/apps/server/src/lib/utils.ts b/apps/server/src/lib/utils.ts index df4c8d4ad3..f4efa4278e 100644 --- a/apps/server/src/lib/utils.ts +++ b/apps/server/src/lib/utils.ts @@ -1,5 +1,5 @@ import type { AppContext, EProviders, Sender } from '../types'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; export const parseHeaders = (token: string) => { const headers = new Headers(); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 88b11e2d37..b90440e96d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -15,7 +15,8 @@ import { writingStyleMatrix, emailTemplate, } from './db/schema'; -import { env, DurableObject, RpcTarget, WorkerEntrypoint } from 'cloudflare:workers'; +import { WorkerEntrypoint, DurableObject, RpcTarget } from 'cloudflare:workers'; +import { env } from './env'; import { EProviders, type ISubscribeBatch, type IThreadBatch } from './types'; import { oAuthDiscoveryMetadata } from 'better-auth/plugins'; import { getZeroDB, verifyToken } from './lib/server-utils'; diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index 58430fecf3..984779b767 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -11,7 +11,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { env } from 'cloudflare:workers'; + +import { env } from './env'; const showLogs = true; diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index c8ef379f99..a182e4e090 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -49,7 +49,7 @@ import { connection } from '../../db/schema'; import type { WSMessage } from 'partyserver'; import { tools as authTools } from './tools'; import { processToolCalls } from './utils'; -import { env } from 'cloudflare:workers'; +import { env } from '../../env'; import type { Connection } from 'agents'; import { openai } from '@ai-sdk/openai'; import { createDb } from '../../db'; diff --git a/apps/server/src/routes/agent/tools.ts b/apps/server/src/routes/agent/tools.ts index e7e92efd9a..2967bbe040 100644 --- a/apps/server/src/routes/agent/tools.ts +++ b/apps/server/src/routes/agent/tools.ts @@ -4,7 +4,7 @@ import { generateText, tool } from 'ai'; import { getZeroAgent } from '../../lib/server-utils'; import { colors } from '../../lib/prompts'; -import { env } from 'cloudflare:workers'; +import { env } from '../../env'; import { Tools } from '../../types'; import { z } from 'zod'; diff --git a/apps/server/src/routes/ai.ts b/apps/server/src/routes/ai.ts index 3d6b353672..cf4acbeb68 100644 --- a/apps/server/src/routes/ai.ts +++ b/apps/server/src/routes/ai.ts @@ -2,7 +2,7 @@ import { getCurrentDateContext, GmailSearchAssistantSystemPrompt } from '../lib/ import { systemPrompt } from '../services/call-service/system-prompt'; import { composeEmail } from '../trpc/routes/ai/compose'; import { getZeroAgent } from '../lib/server-utils'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; import { Tools } from '../types'; diff --git a/apps/server/src/routes/autumn.ts b/apps/server/src/routes/autumn.ts index cc41b7215c..d2bb1113ad 100644 --- a/apps/server/src/routes/autumn.ts +++ b/apps/server/src/routes/autumn.ts @@ -1,6 +1,6 @@ import { fetchPricingTable } from 'autumn-js'; import type { HonoContext } from '../ctx'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; import { Hono } from 'hono'; const sanitizeCustomerBody = (body: any) => { diff --git a/apps/server/src/services/writing-style-service.ts b/apps/server/src/services/writing-style-service.ts index 0a98bee9f0..e3599ea32a 100644 --- a/apps/server/src/services/writing-style-service.ts +++ b/apps/server/src/services/writing-style-service.ts @@ -3,7 +3,7 @@ import { mapToObj, pipe, entries, sortBy, take, fromEntries } from 'remeda'; import { writingStyleMatrix } from '../db/schema'; -import { env } from 'cloudflare:workers'; +import { env } from '../env'; import { google } from '@ai-sdk/google'; import { jsonrepair } from 'jsonrepair'; import { generateObject } from 'ai'; diff --git a/apps/server/src/trpc/routes/ai/compose.ts b/apps/server/src/trpc/routes/ai/compose.ts index 7ce7fef77f..51619e0425 100644 --- a/apps/server/src/trpc/routes/ai/compose.ts +++ b/apps/server/src/trpc/routes/ai/compose.ts @@ -9,7 +9,7 @@ import { activeConnectionProcedure } from '../../trpc'; import { getPrompt } from '../../../lib/brain'; import { stripHtml } from 'string-strip-html'; import { EPrompts } from '../../../types'; -import { env } from 'cloudflare:workers'; +import { env } from '../../../env'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; import { z } from 'zod'; diff --git a/apps/server/src/trpc/routes/ai/search.ts b/apps/server/src/trpc/routes/ai/search.ts index d22d0977a8..9e85a1e1fa 100644 --- a/apps/server/src/trpc/routes/ai/search.ts +++ b/apps/server/src/trpc/routes/ai/search.ts @@ -3,9 +3,9 @@ import { OutlookSearchAssistantSystemPrompt, } from '../../../lib/prompts'; import { activeDriverProcedure } from '../../trpc'; -import { env } from 'cloudflare:workers'; import { openai } from '@ai-sdk/openai'; import { generateObject } from 'ai'; +import { env } from '../../../env'; import { z } from 'zod'; export const generateSearchQuery = activeDriverProcedure diff --git a/apps/server/src/trpc/routes/brain.ts b/apps/server/src/trpc/routes/brain.ts index 75c773b893..1a41125675 100644 --- a/apps/server/src/trpc/routes/brain.ts +++ b/apps/server/src/trpc/routes/brain.ts @@ -2,7 +2,7 @@ import { disableBrainFunction, getPrompts } from '../../lib/brain'; import { EProviders, EPrompts, type ISubscribeBatch } from '../../types'; import { activeConnectionProcedure, router } from '../trpc'; import { setSubscribedState } from '../../lib/utils'; -import { env } from 'cloudflare:workers'; +import { env } from '../../env'; import { z } from 'zod'; const labelSchema = z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba301b8c9d..577497c5af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,7 +307,7 @@ importers: version: 0.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) novel: specifier: 1.0.2 - version: 1.0.2(patch_hash=4e642d3ebfc8b39cf357dfe855ef6e0f5ebb3f6705776e1d7f2acc7ee8a973ec)(@tiptap/extension-code-block@2.23.0(@tiptap/core@2.23.0(@tiptap/pm@2.23.0))(@tiptap/pm@2.23.0))(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.0.2(@tiptap/extension-code-block@2.23.0(@tiptap/core@2.23.0(@tiptap/pm@2.23.0))(@tiptap/pm@2.23.0))(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: 2.4.0 version: 2.4.0(react-router@7.6.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) @@ -17153,7 +17153,7 @@ snapshots: normalize-path@3.0.0: {} - novel@1.0.2(patch_hash=4e642d3ebfc8b39cf357dfe855ef6e0f5ebb3f6705776e1d7f2acc7ee8a973ec)(@tiptap/extension-code-block@2.23.0(@tiptap/core@2.23.0(@tiptap/pm@2.23.0))(@tiptap/pm@2.23.0))(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + novel@1.0.2(@tiptap/extension-code-block@2.23.0(@tiptap/core@2.23.0(@tiptap/pm@2.23.0))(@tiptap/pm@2.23.0))(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.1.0) '@tiptap/core': 2.23.0(@tiptap/pm@2.23.0) From bcf2d17b44523a1544a514162ac2844ae6c54fc7 Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:43:42 -0700 Subject: [PATCH 32/48] Integrate Dormroom for durable object management and queryable storage (#1873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR integrates Dormroom for managing durable objects and enabling queryable storage for email agents. It improves how we handle agent instances and database operations, making our system more robust and maintainable. ## Type of Change - ✨ New feature (non-breaking change which adds functionality) - ⚡ Performance improvement ## Areas Affected - [x] Email Integration (Gmail, IMAP, etc.) - [x] Data Storage/Management - [x] API Endpoints ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings ## Key Changes 1. Added Dormroom dependency for better durable object management 2. Implemented `@Migratable` and `@Queryable` decorators for ZeroDriver class 3. Created new `getZeroClient` function to replace direct agent instantiation 4. Fixed redirect header handling to prevent header persistence after redirects 5. Improved folder sync logic to skip unnecessary operations for aggregate instances 6. Standardized environment variable access through instance properties 7. Added structured database migrations for thread storage 8. Enhanced type safety with proper environment type definitions These changes improve our agent architecture and make database operations more reliable while maintaining backward compatibility. ## Summary by CodeRabbit * **New Features** * Introduced new methods for database size reporting and conditional folder synchronization. * Added structured environment typing for improved configuration management. * **Refactor** * Standardized environment variable access throughout the server and agent components. * Migrated database schema management to a migration system. * Updated client acquisition logic to utilize execution context. * **Bug Fixes** * Improved redirect handling in the mail provider to clean up response headers after navigation. * Adjusted redirect logic to only trigger under specific authorization conditions. * **Chores** * Added the "dormroom" dependency to the server application. --- apps/mail/app/root.tsx | 2 - apps/mail/providers/query-provider.tsx | 5 +- apps/server/package.json | 1 + apps/server/src/env.ts | 88 +++++++++++++++++- apps/server/src/lib/server-utils.ts | 18 +++- apps/server/src/main.ts | 27 +++--- apps/server/src/pipelines.ts | 5 +- apps/server/src/routes/agent/index.ts | 124 ++++++++++++++++++------- apps/server/src/trpc/routes/mail.ts | 14 ++- apps/server/src/trpc/trpc.ts | 12 +-- pnpm-lock.yaml | 40 ++++++++ 11 files changed, 270 insertions(+), 66 deletions(-) diff --git a/apps/mail/app/root.tsx b/apps/mail/app/root.tsx index dc6134447f..44b5eb55f4 100644 --- a/apps/mail/app/root.tsx +++ b/apps/mail/app/root.tsx @@ -5,9 +5,7 @@ import { Outlet, Scripts, ScrollRestoration, - useLoaderData, useNavigate, - type LoaderFunctionArgs, type MetaFunction, } from 'react-router'; import { Analytics as DubAnalytics } from '@dub/analytics/react'; diff --git a/apps/mail/providers/query-provider.tsx b/apps/mail/providers/query-provider.tsx index d73b5d010c..22c24247c8 100644 --- a/apps/mail/providers/query-provider.tsx +++ b/apps/mail/providers/query-provider.tsx @@ -97,7 +97,10 @@ export const trpcClient = createTRPCClient({ fetch(url, { ...options, credentials: 'include' }).then((res) => { const currentPath = new URL(window.location.href).pathname; const redirectPath = res.headers.get('X-Zero-Redirect'); - if (!!redirectPath && redirectPath !== currentPath) window.location.href = redirectPath; + if (!!redirectPath && redirectPath !== currentPath) { + window.location.href = redirectPath; + res.headers.delete('X-Zero-Redirect'); + } return res; }), }), diff --git a/apps/server/package.json b/apps/server/package.json index 8f4d256fb5..bc3e5e1baa 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,6 +53,7 @@ "cloudflare": "4.5.0", "date-fns": "^4.1.0", "dedent": "^1.6.0", + "dormroom": "1.0.1", "drizzle-orm": "catalog:", "effect": "3.16.12", "elevenlabs": "1.59.0", diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index c1d22047cf..5f1f8af35a 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -1,4 +1,88 @@ -import { env } from 'cloudflare:workers'; +import type { ThinkingMCP, WorkflowRunner, ZeroDB, ZeroMCP } from './main'; +import type { ZeroAgent, ZeroDriver } from './routes/agent'; +import { env as _env } from 'cloudflare:workers'; +import type { QueryableHandler } from 'dormroom'; +export type ZeroEnv = { + ZERO_DRIVER: DurableObjectNamespace; + ZERO_DB: DurableObjectNamespace; + ZERO_AGENT: DurableObjectNamespace; + ZERO_MCP: DurableObjectNamespace; + THINKING_MCP: DurableObjectNamespace; + WORKFLOW_RUNNER: DurableObjectNamespace; + HYPERDRIVE: { connectionString: string }; + snoozed_emails: KVNamespace; + gmail_sub_age: KVNamespace; + subscribe_queue: Queue; + AI: Ai; + gmail_history_id: KVNamespace; + gmail_processing_threads: KVNamespace; + subscribed_accounts: KVNamespace; + connection_labels: KVNamespace; + prompts_storage: KVNamespace; + NODE_ENV: 'local' | 'development' | 'production'; + JWT_SECRET: 'secret'; + ELEVENLABS_API_KEY: '1234567890'; + DISABLE_CALLS: 'true' | ''; + DROP_AGENT_TABLES: 'false'; + THREAD_SYNC_MAX_COUNT: '5' | '20' | '10'; + THREAD_SYNC_LOOP: 'false' | 'true'; + DISABLE_WORKFLOWS: 'true'; + AUTORAG_ID: ''; + USE_OPENAI: 'true'; + CLOUDFLARE_ACCOUNT_ID: '397b3b4fac213b9b382d0f1fafdbb215'; + CLOUDFLARE_API_TOKEN: 'wbrJ9McsQhjCxv1pzxLLK8keT-0tM1ab-QbmESg6'; + BASE_URL: string; + VITE_PUBLIC_APP_URL: string; + DATABASE_URL: string; + BETTER_AUTH_SECRET: string; + BETTER_AUTH_URL: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + RESEND_API_KEY: string; + VITE_PUBLIC_POSTHOG_KEY: string; + VITE_PUBLIC_POSTHOG_HOST: string; + COOKIE_DOMAIN: string; + BETTER_AUTH_TRUSTED_ORIGINS: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + GOOGLE_REDIRECT_URI: string; + GOOGLE_APPLICATION_CREDENTIALS: string; + HISTORY_OFFSET: string; + ZERO_CLIENT_ID: string; + ZERO_CLIENT_SECRET: string; + VITE_PUBLIC_BACKEND_URL: string; + REDIS_URL: string; + REDIS_TOKEN: string; + OPENAI_API_KEY: string; + BRAIN_URL: string; + COMPOSIO_API_KEY: string; + GROQ_API_KEY: string; + EARLY_ACCESS_ENABLED: string; + GOOGLE_GENERATIVE_AI_API_KEY: string; + AUTUMN_SECRET_KEY: string; + AI_SYSTEM_PROMPT: string; + PERPLEXITY_API_KEY: string; + TWILIO_ACCOUNT_SID: string; + TWILIO_AUTH_TOKEN: string; + TWILIO_PHONE_NUMBER: string; + VITE_PUBLIC_ELEVENLABS_AGENT_ID: string; + REACT_SCAN: string; + MICROSOFT_CLIENT_ID: string; + MICROSOFT_CLIENT_SECRET: string; + VOICE_SECRET: string; + ARCADE_API_KEY: string; + OPENAI_MODEL: string; + OPENAI_MINI_MODEL: string; + ANTHROPIC_API_KEY: string; + GOOGLE_S_ACCOUNT: string; + AXIOM_API_TOKEN: string; + AXIOM_DATASET: string; + THREADS_BUCKET: R2Bucket; + thread_queue: Queue; + VECTORIZE: VectorizeIndex; + VECTORIZE_MESSAGE: VectorizeIndex; +}; + +const env = _env as ZeroEnv; export { env }; -export type Env = typeof env; diff --git a/apps/server/src/lib/server-utils.ts b/apps/server/src/lib/server-utils.ts index ea0fb66314..c41a0e2d10 100644 --- a/apps/server/src/lib/server-utils.ts +++ b/apps/server/src/lib/server-utils.ts @@ -1,10 +1,11 @@ import { getContext } from 'hono/context-storage'; import { connection } from '../db/schema'; import type { HonoContext } from '../ctx'; -import { env } from '../env'; +import { createClient } from 'dormroom'; import { createDriver } from './driver'; import { eq } from 'drizzle-orm'; import { createDb } from '../db'; +import { env } from '../env'; export const getZeroDB = async (userId: string) => { const stub = env.ZERO_DB.get(env.ZERO_DB.idFromName(userId)); @@ -12,6 +13,21 @@ export const getZeroDB = async (userId: string) => { return rpcTarget; }; +export const getZeroClient = async (connectionId: string, executionCtx: ExecutionContext) => { + const agent = createClient({ + doNamespace: env.ZERO_DRIVER, + ctx: executionCtx, + configs: [{ name: connectionId }], + }).stub; + + await agent.setName(connectionId); + await agent.setupAuth(); + + executionCtx.waitUntil(agent.syncFolders()); + + return agent; +}; + export const getZeroAgent = async (connectionId: string) => { const stub = env.ZERO_DRIVER.get(env.ZERO_DRIVER.idFromName(connectionId)); const rpcTarget = await stub.setMetaData(connectionId); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b90440e96d..6958ad529d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -16,17 +16,15 @@ import { emailTemplate, } from './db/schema'; import { WorkerEntrypoint, DurableObject, RpcTarget } from 'cloudflare:workers'; -import { env } from './env'; import { EProviders, type ISubscribeBatch, type IThreadBatch } from './types'; +import { getZeroClient, getZeroDB, verifyToken } from './lib/server-utils'; import { oAuthDiscoveryMetadata } from 'better-auth/plugins'; -import { getZeroDB, verifyToken } from './lib/server-utils'; import { eq, and, desc, asc, inArray } from 'drizzle-orm'; import { ThinkingMCP } from './lib/sequential-thinking'; import { ZeroAgent, ZeroDriver } from './routes/agent'; import { contextStorage } from 'hono/context-storage'; import { defaultUserSettings } from './lib/schemas'; import { createLocalJWKSet, jwtVerify } from 'jose'; -import { getZeroAgent } from './lib/server-utils'; import { enableBrainFunction } from './lib/brain'; import { trpcServer } from '@hono/trpc-server'; import { agentsMiddleware } from 'hono-agents'; @@ -34,6 +32,7 @@ import { ZeroMCP } from './routes/agent/mcp'; import { publicRouter } from './routes/auth'; import { WorkflowRunner } from './pipelines'; import { autumnApi } from './routes/autumn'; +import { env, type ZeroEnv } from './env'; import type { HonoContext } from './ctx'; import { createDb, type DB } from './db'; import { createAuth } from './lib/auth'; @@ -193,8 +192,8 @@ export class DbRpcDO extends RpcTarget { } } -class ZeroDB extends DurableObject { - db: DB = createDb(env.HYPERDRIVE.connectionString).db; +class ZeroDB extends DurableObject { + db: DB = createDb(this.env.HYPERDRIVE.connectionString).db; async setMetaData(userId: string) { return new DbRpcDO(this, userId); @@ -776,8 +775,14 @@ const app = new Hono() return c.json({ message: 'OK' }, { status: 200 }); } }); -export default class Entry extends WorkerEntrypoint { +export default class Entry extends WorkerEntrypoint { async fetch(request: Request): Promise { + // const url = new URL(request.url); + // if (url.pathname === '/__studio') { + // return await studio(request, env.ZERO_DRIVER, { + // basicAuth: { username: 'admin', password: 'password' }, + // }); + // } return app.fetch(request, this.env, this.ctx); } async queue(batch: MessageBatch) { @@ -827,7 +832,7 @@ export default class Entry extends WorkerEntrypoint { } async scheduled() { console.log('[SCHEDULED] Checking for expired subscriptions...'); - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); const allAccounts = await db.query.connection.findMany({ where: (fields, { isNotNull, and }) => and(isNotNull(fields.accessToken), isNotNull(fields.refreshToken)), @@ -848,7 +853,7 @@ export default class Entry extends WorkerEntrypoint { const listResp: { keys: { name: string; metadata?: { wakeAt?: string } }[]; cursor?: string; - } = await env.snoozed_emails.list({ cursor, limit: 1000 }); + } = await this.env.snoozed_emails.list({ cursor, limit: 1000 }); cursor = listResp.cursor; for (const key of listResp.keys) { @@ -875,7 +880,7 @@ export default class Entry extends WorkerEntrypoint { await Promise.all( Object.entries(unsnoozeMap).map(async ([connectionId, { threadIds, keyNames }]) => { try { - const agent = await getZeroAgent(connectionId); + const agent = await getZeroClient(connectionId, this.ctx); await agent.queue('unsnoozeThreadsHandler', { connectionId, threadIds, keyNames }); } catch (error) { console.error('Failed to enqueue unsnooze tasks', { connectionId, threadIds, error }); @@ -885,7 +890,7 @@ export default class Entry extends WorkerEntrypoint { await Promise.all( allAccounts.map(async ({ id, providerId }) => { - const lastSubscribed = await env.gmail_sub_age.get(`${id}__${providerId}`); + const lastSubscribed = await this.env.gmail_sub_age.get(`${id}__${providerId}`); if (lastSubscribed) { const subscriptionDate = new Date(lastSubscribed); @@ -906,7 +911,7 @@ export default class Entry extends WorkerEntrypoint { ); await Promise.all( expiredSubscriptions.map(async ({ connectionId, providerId }) => { - await env.subscribe_queue.send({ connectionId, providerId }); + await this.env.subscribe_queue.send({ connectionId, providerId }); }), ); } diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts index 35b0c375ee..cc73c569d1 100644 --- a/apps/server/src/pipelines.ts +++ b/apps/server/src/pipelines.ts @@ -23,6 +23,7 @@ import { type gmail_v1 } from '@googleapis/gmail'; import { Effect, Console, Logger } from 'effect'; import { connection } from './db/schema'; import { EProviders } from './types'; +import type { ZeroEnv } from './env'; import { EPrompts } from './types'; import { eq } from 'drizzle-orm'; import { createDb } from './db'; @@ -149,8 +150,8 @@ export type WorkflowError = | ThreadWorkflowError | UnsupportedWorkflowError; -export class WorkflowRunner extends DurableObject { - constructor(state: DurableObjectState, env: Env) { +export class WorkflowRunner extends DurableObject { + constructor(state: DurableObjectState, env: ZeroEnv) { super(state, env); } diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index a182e4e090..39b63efff8 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -38,19 +38,20 @@ import { generateWhatUserCaresAbout, type UserTopic } from '../../lib/analyze/in import { DurableObjectOAuthClientProvider } from 'agents/mcp/do-oauth-client-provider'; import { AiChatPrompt, GmailSearchAssistantSystemPrompt } from '../../lib/prompts'; import { connectionToDriver, getZeroSocketAgent } from '../../lib/server-utils'; +import { Migratable, Queryable, Transfer } from 'dormroom'; import type { CreateDraftData } from '../../lib/schemas'; import { withRetry } from '../../lib/gmail-rate-limit'; import { getPrompt } from '../../pipelines.effect'; import { AIChatAgent } from 'agents/ai-chat-agent'; import { ToolOrchestrator } from './orchestrator'; import { getPromptName } from '../../pipelines'; +import { Agent, type Connection } from 'agents'; import { anthropic } from '@ai-sdk/anthropic'; +import { env, type ZeroEnv } from '../../env'; import { connection } from '../../db/schema'; import type { WSMessage } from 'partyserver'; import { tools as authTools } from './tools'; import { processToolCalls } from './utils'; -import { env } from '../../env'; -import type { Connection } from 'agents'; import { openai } from '@ai-sdk/openai'; import { createDb } from '../../db'; import { DriverRpcDO } from './rpc'; @@ -278,16 +279,15 @@ export type FolderSyncEffect = Effect.Effect< export type FolderSyncSuccess = FolderSyncResult; export type FolderSyncFailure = FolderSyncErrors; -export class ZeroDriver extends AIChatAgent { - private foldersInSync: Map = new Map(); - private syncThreadsInProgress: Map = new Map(); - private driver: MailManager | null = null; - private agent: DurableObjectStub | null = null; - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env); - if (shouldDropTables) this.dropTables(); - void this.sql` - CREATE TABLE IF NOT EXISTS threads ( +@Migratable({ + migrations: { + 1: [ + `CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY + )`, + ], + 2: [ + `CREATE TABLE IF NOT EXISTS threads ( id TEXT PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -298,8 +298,24 @@ export class ZeroDriver extends AIChatAgent { latest_subject TEXT, latest_label_ids TEXT, categories TEXT - ); - `; + );`, + ], + }, +}) +@Queryable() +export class ZeroDriver extends Agent { + transfer = new Transfer(this); + private foldersInSync: Map = new Map(); + private syncThreadsInProgress: Map = new Map(); + private driver: MailManager | null = null; + private agent: DurableObjectStub | null = null; + constructor(ctx: DurableObjectState, env: ZeroEnv) { + super(ctx, env); + if (shouldDropTables) this.dropTables(); + } + + getDatabaseSize() { + return this.ctx.storage.sql.databaseSize; } getAllSubjects() { @@ -603,20 +619,40 @@ export class ZeroDriver extends AIChatAgent { public async setupAuth() { if (this.name === 'general') return; if (!this.driver) { - const { db, conn } = createDb(env.HYPERDRIVE.connectionString); + this.agent = await getZeroSocketAgent(this.name); + const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); const _connection = await db.query.connection.findFirst({ where: eq(connection.id, this.name), }); if (_connection) this.driver = connectionToDriver(_connection); this.ctx.waitUntil(conn.end()); - const threadCount = await this.getThreadCount(); - if (threadCount < maxCount) { - this.ctx.waitUntil(this.syncThreads('inbox')); - this.ctx.waitUntil(this.syncThreads('sent')); - this.ctx.waitUntil(this.syncThreads('spam')); - } } } + + async syncFolders() { + if (this.name === 'general') return; + // Skip sync for aggregate instances - they should only mirror primary operations + // The multi-stub pattern ensures aggregate gets operations in background + if (this.name.includes('aggregate')) { + console.log('[syncFolders] Skipping sync for aggregate instance'); + return; + } + + const threadCount = await this.getThreadCount(); + if (threadCount < maxCount) { + console.log( + `[syncFolders] Starting folder sync for ${this.name} (threadCount: ${threadCount})`, + ); + this.ctx.waitUntil(this.syncThreads('inbox')); + this.ctx.waitUntil(this.syncThreads('sent')); + this.ctx.waitUntil(this.syncThreads('spam')); + } else { + console.log( + `[syncFolders] Skipping sync for ${this.name} - threadCount (${threadCount}) >= maxCount (${maxCount})`, + ); + } + } + async rawListThreads(params: { folder: string; query?: string; @@ -831,7 +867,8 @@ export class ZeroDriver extends AIChatAgent { } async syncThread({ threadId }: { threadId: string }): Promise { - if (this.name === 'general') { + if (this.name === 'general' || this.name.includes('aggregate')) { + console.log(`[syncThread] Skipping sync for ${this.name} instance - thread ${threadId}`); return { success: true, threadId, broadcastSent: false }; } @@ -912,7 +949,7 @@ export class ZeroDriver extends AIChatAgent { // Store thread data in bucket yield* Effect.tryPromise(() => - env.THREADS_BUCKET.put(this.getThreadKey(threadId), JSON.stringify(threadData), { + this.env.THREADS_BUCKET.put(this.getThreadKey(threadId), JSON.stringify(threadData), { customMetadata: { threadId }, }), ).pipe( @@ -1026,6 +1063,21 @@ export class ZeroDriver extends AIChatAgent { } async syncThreads(folder: string): Promise { + // Skip sync for aggregate instances - they should only mirror primary operations + if (this.name.includes('aggregate')) { + console.log(`[syncThreads] Skipping sync for aggregate instance - folder ${folder}`); + return { + synced: 0, + message: 'Skipped aggregate instance', + folder, + pagesProcessed: 0, + totalThreads: 0, + successfulSyncs: 0, + failedSyncs: 0, + broadcastSent: false, + }; + } + if (!this.driver) { console.error(`[syncThreads] No driver available for folder ${folder}`); return { @@ -1237,7 +1289,7 @@ export class ZeroDriver extends AIChatAgent { } async inboxRag(query: string) { - if (!env.AUTORAG_ID) { + if (!this.env.AUTORAG_ID) { console.warn('[inboxRag] AUTORAG_ID not configured - RAG search disabled'); return { result: 'Not enabled', data: [] }; } @@ -1250,7 +1302,7 @@ export class ZeroDriver extends AIChatAgent { folder_filter: `${this.name}/`, }); - const answer = await env.AI.autorag(env.AUTORAG_ID).aiSearch({ + const answer = await this.env.AI.autorag(this.env.AUTORAG_ID).aiSearch({ query: query, // rewrite_query: true, max_num_results: 3, @@ -1301,7 +1353,7 @@ export class ZeroDriver extends AIChatAgent { const genQueryEffect = Effect.tryPromise(() => generateText({ - model: openai(env.OPENAI_MODEL || 'gpt-4o'), + model: openai(this.env.OPENAI_MODEL || 'gpt-4o'), system: GmailSearchAssistantSystemPrompt(), prompt: params.query, }).then((response) => response.text), @@ -1662,7 +1714,7 @@ export class ZeroDriver extends AIChatAgent { } satisfies IGetThreadResponse; } const row = result[0] as { latest_label_ids: string }; - const storedThread = await env.THREADS_BUCKET.get(this.getThreadKey(id)); + const storedThread = await this.env.THREADS_BUCKET.get(this.getThreadKey(id)); let messages: ParsedMessage[] = storedThread ? (JSON.parse(await storedThread.text()) as IGetThreadResponse).messages @@ -1702,7 +1754,7 @@ export class ZeroDriver extends AIChatAgent { } if (keyNames.length) { - await Promise.all(keyNames.map((k: string) => env.snoozed_emails.delete(k))); + await Promise.all(keyNames.map((k: string) => this.env.snoozed_emails.delete(k))); } } catch (error) { console.error('[AGENT][unsnoozeThreadsHandler] Failed', { connectionId, threadIds, error }); @@ -1744,29 +1796,29 @@ export class ZeroDriver extends AIChatAgent { // } } -export class ZeroAgent extends AIChatAgent { +export class ZeroAgent extends AIChatAgent { private chatMessageAbortControllers: Map = new Map(); private connectionThreadIds: Map = new Map(); async registerZeroMCP() { - await this.mcp.connect(env.VITE_PUBLIC_BACKEND_URL + '/sse', { + await this.mcp.connect(this.env.VITE_PUBLIC_BACKEND_URL + '/sse', { transport: { authProvider: new DurableObjectOAuthClientProvider( this.ctx.storage, 'zero-mcp', - env.VITE_PUBLIC_BACKEND_URL, + this.env.VITE_PUBLIC_BACKEND_URL, ), }, }); } async registerThinkingMCP() { - await this.mcp.connect(env.VITE_PUBLIC_BACKEND_URL + '/mcp/thinking/sse', { + await this.mcp.connect(this.env.VITE_PUBLIC_BACKEND_URL + '/mcp/thinking/sse', { transport: { authProvider: new DurableObjectOAuthClientProvider( this.ctx.storage, 'thinking-mcp', - env.VITE_PUBLIC_BACKEND_URL, + this.env.VITE_PUBLIC_BACKEND_URL, ), }, }); @@ -1809,9 +1861,9 @@ export class ZeroAgent extends AIChatAgent { ); const model = - env.USE_OPENAI === 'true' - ? openai(env.OPENAI_MODEL || 'gpt-4o') - : anthropic(env.OPENAI_MODEL || 'claude-3-7-sonnet-20250219'); + this.env.USE_OPENAI === 'true' + ? openai(this.env.OPENAI_MODEL || 'gpt-4o') + : anthropic(this.env.OPENAI_MODEL || 'claude-3-7-sonnet-20250219'); const result = streamText({ model, diff --git a/apps/server/src/trpc/routes/mail.ts b/apps/server/src/trpc/routes/mail.ts index cd35d92c60..7c35541311 100644 --- a/apps/server/src/trpc/routes/mail.ts +++ b/apps/server/src/trpc/routes/mail.ts @@ -5,12 +5,13 @@ import { } from '../../lib/driver/types'; import { updateWritingStyleMatrix } from '../../services/writing-style-service'; import { activeDriverProcedure, router, privateProcedure } from '../trpc'; +import { getZeroAgent, getZeroClient } from '../../lib/server-utils'; import { processEmailHtml } from '../../lib/email-processor'; import { defaultPageSize, FOLDERS } from '../../lib/utils'; import { serializedFileSchema } from '../../lib/schemas'; import type { DeleteAllSpamResponse } from '../../types'; -import { getZeroAgent } from '../../lib/server-utils'; - +import { getContext } from 'hono/context-storage'; +import { type HonoContext } from '../../ctx'; import { env } from 'cloudflare:workers'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -39,7 +40,8 @@ export const mailRouter = router({ .output(IGetThreadResponseSchema) .query(async ({ input, ctx }) => { const { activeConnection } = ctx; - const agent = await getZeroAgent(activeConnection.id); + const executionCtx = getContext().executionCtx; + const agent = await getZeroClient(activeConnection.id, executionCtx); return await agent.getThread(input.id, true); }), count: activeDriverProcedure @@ -213,7 +215,8 @@ export const mailRouter = router({ ) .mutation(async ({ input, ctx }) => { const { activeConnection } = ctx; - const agent = await getZeroAgent(activeConnection.id); + const executionCtx = getContext().executionCtx; + const agent = await getZeroClient(activeConnection.id, executionCtx); const { threadIds } = await agent.normalizeIds(input.ids); if (!threadIds.length) { @@ -257,7 +260,8 @@ export const mailRouter = router({ ) .mutation(async ({ input, ctx }) => { const { activeConnection } = ctx; - const agent = await getZeroAgent(activeConnection.id); + const executionCtx = getContext().executionCtx; + const agent = await getZeroClient(activeConnection.id, executionCtx); const { threadIds } = await agent.normalizeIds(input.ids); if (!threadIds.length) { diff --git a/apps/server/src/trpc/trpc.ts b/apps/server/src/trpc/trpc.ts index 9732bf9948..3bb2e55f3e 100644 --- a/apps/server/src/trpc/trpc.ts +++ b/apps/server/src/trpc/trpc.ts @@ -69,12 +69,12 @@ export const activeDriverProcedure = activeConnectionProcedure.use(async ({ ctx, accessToken: null, refreshToken: null, }); - - ctx.c.header( - 'X-Zero-Redirect', - `/settings/connections?disconnectedConnectionId=${activeConnection.id}`, - ); - + if (activeConnection.accessToken) { + ctx.c.header( + 'X-Zero-Redirect', + `/settings/connections?disconnectedConnectionId=${activeConnection.id}`, + ); + } throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Connection expired. Please reconnect.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 577497c5af..2329b49176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -585,6 +585,9 @@ importers: dedent: specifier: ^1.6.0 version: 1.6.0 + dormroom: + specifier: 1.0.1 + version: 1.0.1 drizzle-orm: specifier: 'catalog:' version: 0.43.1(@cloudflare/workers-types@4.20250628.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(better-sqlite3@11.10.0)(kysely@0.28.2)(postgres@3.4.5) @@ -5664,6 +5667,9 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dormroom@1.0.1: + resolution: {integrity: sha512-pXD9SSm73YMnNDQDkRgTlQAYthJtfcm6x++epFEIV9Mn6C3+cUZ9XlUSyyMGwc8nQjDERPtTwMeeEAzG9j/QKg==} + dotenv-cli@8.0.0: resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} hasBin: true @@ -7314,6 +7320,9 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + migratable-object@0.0.12: + resolution: {integrity: sha512-CpsSzGNub/HsXg8XCFVtHQAMlrgsmW0Ni4kj/AXLCfE2GrDoWnDU1HE4u9PCgh+IbOlVqBzljqD5GczRakrQ2A==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -7456,6 +7465,9 @@ packages: typescript: optional: true + multistub@0.0.8: + resolution: {integrity: sha512-Tx9GYYNTKEnUSpzCz/p/ooyGgj/bl8ezLFvsU3AX1EjF7ptfqoI9n63na5tsgGX/ZssTy9WY8KiUaMaIu3/CiQ==} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -8180,6 +8192,9 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + queryable-object@0.0.8: + resolution: {integrity: sha512-iJDRf4ra/UV+xO+FblF0cvQJleN2w3Z4mG7QhLVvKNJoyikAhDyfiW4CL/AXK+fNg3nLvBAgzpSjpbFujCh58g==} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -8469,6 +8484,9 @@ packages: remeda@2.21.3: resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==} + remote-sql-cursor@0.1.9: + resolution: {integrity: sha512-2wwbOiyIkr+McQ45kAZT+x7britqG+cI2NLDNmDDSc+vUlk40UAhpfhJ/jEtM6NQ/GyDvxhNjfm6DTTwseUTpw==} + repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} @@ -9099,6 +9117,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + transferable-object@0.0.22: + resolution: {integrity: sha512-my5Y2G90U4BBwXEnR6+7zpCBsgPOiF7JEkuNzWwq42YTiCyD1P39JaFoRJEO1tcZ423TdGrLSlW5DzgKZgCTNA==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -14919,6 +14940,13 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dormroom@1.0.1: + dependencies: + migratable-object: 0.0.12 + multistub: 0.0.8 + queryable-object: 0.0.8 + transferable-object: 0.0.22 + dotenv-cli@8.0.0: dependencies: cross-spawn: 7.0.6 @@ -16929,6 +16957,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + migratable-object@0.0.12: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -17088,6 +17118,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + multistub@0.0.8: {} + mustache@4.2.0: {} mute-stream@2.0.0: {} @@ -17806,6 +17838,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queryable-object@0.0.8: {} + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -18232,6 +18266,8 @@ snapshots: dependencies: type-fest: 4.41.0 + remote-sql-cursor@0.1.9: {} + repeat-string@1.6.1: {} require-directory@2.1.1: {} @@ -18990,6 +19026,10 @@ snapshots: dependencies: punycode: 2.3.1 + transferable-object@0.0.22: + dependencies: + remote-sql-cursor: 0.1.9 + trim-lines@3.0.1: {} trough@2.2.0: {} From 824b2325fc29f976c3c7b0f1aabace539723ba2b Mon Sep 17 00:00:00 2001 From: "Yadong (Adam) Zhang" Date: Sat, 2 Aug 2025 00:53:12 +0800 Subject: [PATCH 33/48] fix: update locale keys for Chinese language support (#1878) --- apps/mail/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mail/locales.ts b/apps/mail/locales.ts index 98c280f558..e0d338353e 100644 --- a/apps/mail/locales.ts +++ b/apps/mail/locales.ts @@ -18,8 +18,8 @@ export const locales = { hu: 'Hungarian', fa: 'Persian', vi: 'Vietnamese', - 'zh-CN': 'Chinese (Simplified)', - 'zh-TW': 'Chinese (Traditional)', + 'zh_CN': 'Chinese (Simplified)', + 'zh_TW': 'Chinese (Traditional)', af: 'Afrikaans', sq: 'Albanian', am: 'Amharic', From 1ea4bfe69c4aaeb29338c25b375457fdda39c3b1 Mon Sep 17 00:00:00 2001 From: Brandon McConnell Date: Fri, 1 Aug 2025 12:58:19 -0400 Subject: [PATCH 34/48] Upgrade to Tailwind CSS v4 (#1881) Co-authored-by: Adam <13007539+MrgSub@users.noreply.github.com> Co-authored-by: Aj Wazzan --- .cursor/rules/tailwind-css-v4.mdc | 217 +++++ AGENT.md | 1 - apps/mail/app/(auth)/login/login-client.tsx | 4 +- apps/mail/app/(full-width)/about.tsx | 2 +- apps/mail/app/(full-width)/contributors.tsx | 4 +- apps/mail/app/(full-width)/privacy.tsx | 2 +- apps/mail/app/(full-width)/terms.tsx | 2 +- apps/mail/app/(routes)/developer/page.tsx | 2 +- .../app/(routes)/settings/general/page.tsx | 6 +- .../app/(routes)/settings/labels/page.tsx | 2 +- apps/mail/app/globals.css | 450 ++++++----- apps/mail/components.json | 2 +- apps/mail/components/connection/add.tsx | 2 +- .../components/context/loading-context.tsx | 2 +- .../components/context/sidebar-context.tsx | 2 +- apps/mail/components/create/ai-chat.tsx | 10 +- apps/mail/components/create/ai-textarea.tsx | 2 +- .../mail/components/create/email-composer.tsx | 14 +- apps/mail/components/create/prosemirror.css | 8 +- .../components/create/template-button.tsx | 4 +- apps/mail/components/home/HomeContent.tsx | 28 +- apps/mail/components/home/footer.tsx | 4 +- apps/mail/components/magicui/file-tree.tsx | 2 +- .../components/mail/attachment-dialog.tsx | 2 +- .../components/mail/attachments-accordion.tsx | 4 +- apps/mail/components/mail/mail-display.tsx | 10 +- apps/mail/components/mail/mail-list.tsx | 2 +- apps/mail/components/mail/mail-skeleton.tsx | 6 +- apps/mail/components/mail/mail.tsx | 16 +- apps/mail/components/mail/navbar.tsx | 2 +- apps/mail/components/mail/note-panel.tsx | 2 +- apps/mail/components/mail/render-labels.tsx | 2 +- apps/mail/components/mail/reply-composer.tsx | 2 +- .../components/mail/select-all-checkbox.tsx | 2 +- apps/mail/components/mail/thread-display.tsx | 2 +- apps/mail/components/navigation.tsx | 2 +- apps/mail/components/pricing/comparision.tsx | 10 +- apps/mail/components/pricing/pricing-card.tsx | 10 +- apps/mail/components/ui/ai-sidebar.tsx | 6 +- apps/mail/components/ui/chart.tsx | 4 +- apps/mail/components/ui/context-menu.tsx | 10 +- apps/mail/components/ui/dialog.tsx | 6 +- apps/mail/components/ui/dropdown-menu.tsx | 10 +- apps/mail/components/ui/envelop.tsx | 2 +- apps/mail/components/ui/input-otp.tsx | 2 +- apps/mail/components/ui/nav-main.tsx | 4 +- apps/mail/components/ui/nav-user.tsx | 2 +- apps/mail/components/ui/navigation-menu.tsx | 6 +- apps/mail/components/ui/pricing-dialog.tsx | 18 +- apps/mail/components/ui/prompts-dialog.tsx | 2 +- apps/mail/components/ui/scroll-area.tsx | 4 +- apps/mail/components/ui/select.tsx | 8 +- apps/mail/components/ui/separator.tsx | 2 +- apps/mail/components/ui/settings-content.tsx | 2 +- apps/mail/components/ui/sidebar.tsx | 30 +- apps/mail/components/ui/tabs.tsx | 2 +- apps/mail/components/ui/text-shimmer.tsx | 2 +- apps/mail/components/ui/toast.tsx | 10 +- apps/mail/package.json | 6 +- apps/mail/tailwind.config.ts | 217 ----- apps/mail/types/tailwind.d.ts | 4 - apps/mail/vite.config.ts | 8 +- packages/tailwind-config/package.json | 12 - packages/tailwind-config/tailwind.config.ts | 19 - packages/tailwind-config/tsconfig.json | 5 - pnpm-lock.yaml | 755 +++++++++++------- 66 files changed, 1092 insertions(+), 912 deletions(-) create mode 100644 .cursor/rules/tailwind-css-v4.mdc delete mode 100644 apps/mail/tailwind.config.ts delete mode 100644 apps/mail/types/tailwind.d.ts delete mode 100644 packages/tailwind-config/package.json delete mode 100644 packages/tailwind-config/tailwind.config.ts delete mode 100644 packages/tailwind-config/tsconfig.json diff --git a/.cursor/rules/tailwind-css-v4.mdc b/.cursor/rules/tailwind-css-v4.mdc new file mode 100644 index 0000000000..7931034bde --- /dev/null +++ b/.cursor/rules/tailwind-css-v4.mdc @@ -0,0 +1,217 @@ +--- +name: tailwind_v4 +description: Guide for using Tailwind CSS v4 instead of v3.x +globs: ["**/*.{js,ts,jsx,tsx,mdx,css}"] +tags: + - tailwind + - css +--- + +# Tailwind CSS v4 + +## Core Changes + +- **CSS-first configuration**: Configuration is now done in CSS instead of JavaScript + - Use `@theme` directive in CSS instead of `tailwind.config.js` + - Example: + ```css + @import "tailwindcss"; + + @theme { + --font-display: "Satoshi", "sans-serif"; + --breakpoint-3xl: 1920px; + --color-avocado-500: oklch(0.84 0.18 117.33); + --ease-fluid: cubic-bezier(0.3, 0, 0, 1); + } + ``` +- Legacy `tailwind.config.js` files can still be imported using the `@config` directive: + ```css + @import "tailwindcss"; + @config "../../tailwind.config.js"; + ``` +- **CSS import syntax**: Use `@import "tailwindcss"` instead of `@tailwind` directives + - Old: `@tailwind base; @tailwind components; @tailwind utilities;` + - New: `@import "tailwindcss";` + +- **Package changes**: + - PostCSS plugin is now `@tailwindcss/postcss` (not `tailwindcss`) + - CLI is now `@tailwindcss/cli` + - Vite plugin is `@tailwindcss/vite` + - No need for `postcss-import` or `autoprefixer` anymore + +- **Native CSS cascade layers**: Uses real CSS `@layer` instead of Tailwind's custom implementation + +## Theme Configuration + +- **CSS theme variables**: All design tokens are available as CSS variables + - Namespace format: `--category-name` (e.g., `--color-blue-500`, `--font-sans`) + - Access in CSS: `var(--color-blue-500)` + - Available namespaces: + - `--color-*` : Color utilities like `bg-red-500` and `text-sky-300` + - `--font-*` : Font family utilities like `font-sans` + - `--text-*` : Font size utilities like `text-xl` + - `--font-weight-*` : Font weight utilities like `font-bold` + - `--tracking-*` : Letter spacing utilities like `tracking-wide` + - `--leading-*` : Line height utilities like `leading-tight` + - `--breakpoint-*` : Responsive breakpoint variants like `sm:*` + - `--container-*` : Container query variants like `@sm:*` and size utilities like `max-w-md` + - `--spacing-*` : Spacing and sizing utilities like `px-4` and `max-h-16` + - `--radius-*` : Border radius utilities like `rounded-sm` + - `--shadow-*` : Box shadow utilities like `shadow-md` + - `--inset-shadow-*` : Inset box shadow utilities like `inset-shadow-xs` + - `--drop-shadow-*` : Drop shadow filter utilities like `drop-shadow-md` + - `--blur-*` : Blur filter utilities like `blur-md` + - `--perspective-*` : Perspective utilities like `perspective-near` + - `--aspect-*` : Aspect ratio utilities like `aspect-video` + - `--ease-*` : Transition timing function utilities like `ease-out` + - `--animate-*` : Animation utilities like `animate-spin` + + +- **Simplified theme configuration**: Many utilities no longer need theme configuration + - Utilities like `grid-cols-12`, `z-40`, and `opacity-70` work without configuration + - Data attributes like `data-selected:opacity-100` don't need configuration + +- **Dynamic spacing scale**: Derived from a single spacing value + - Default: `--spacing: 0.25rem` + - Every multiple of the base value is available (e.g., `mt-21` works automatically) + +- **Overriding theme namespaces**: + - Override entire namespace: `--font-*: initial;` + - Override entire theme: `--*: initial;` + + +## New Features + +- **Container query support**: Built-in now, no plugin needed + - `@container` for container context + - `@sm:`, `@md:`, etc. for container-based breakpoints + - `@max-md:` for max-width container queries + - Combine with `@min-md:@max-xl:hidden` for ranges + +- **3D transforms**: + - `transform-3d` enables 3D transforms + - `rotate-x-*`, `rotate-y-*`, `rotate-z-*` for 3D rotation + - `scale-z-*` for z-axis scaling + - `translate-z-*` for z-axis translation + - `perspective-*` utilities (`perspective-near`, `perspective-distant`, etc.) + - `perspective-origin-*` utilities + - `backface-visible` and `backface-hidden` + +- **Gradient enhancements**: + - Linear gradient angles: `bg-linear-45` (renamed from `bg-gradient-*`) + - Gradient interpolation: `bg-linear-to-r/oklch`, `bg-linear-to-r/srgb` + - Conic and radial gradients: `bg-conic`, `bg-radial-[at_25%_25%]` + +- **Shadow enhancements**: + - `inset-shadow-*` and `inset-ring-*` utilities + - Can be composed with regular `shadow-*` and `ring-*` + +- **New CSS property utilities**: + - `field-sizing-content` for auto-resizing textareas + - `scheme-light`, `scheme-dark` for `color-scheme` property + - `font-stretch-*` utilities for variable fonts + +## New Variants + +- **Composable variants**: Chain variants together + - Example: `group-has-data-potato:opacity-100` + +- **New variants**: + - `starting` variant for `@starting-style` transitions + - `not-*` variant for `:not()` pseudo-class + - `inert` variant for `inert` attribute + - `nth-*` variants (`nth-3:`, `nth-last-5:`, `nth-of-type-4:`, `nth-last-of-type-6:`) + - `in-*` variant (like `group-*` but without adding `group` class) + - `open` variant now supports `:popover-open` + - `**` variant for targeting all descendants + +## Custom Extensions + +- **Custom utilities**: Use `@utility` directive + ```css + @utility tab-4 { + tab-size: 4; + } + ``` + +- **Custom variants**: Use `@variant` directive + ```css + @variant pointer-coarse (@media (pointer: coarse)); + @variant theme-midnight (&:where([data-theme="midnight"] *)); + ``` + +- **Plugins**: Use `@plugin` directive + ```css + @plugin "@tailwindcss/typography"; + ``` + +## Breaking Changes + +- **Removed deprecated utilities**: + - `bg-opacity-*` → Use `bg-black/50` instead + - `text-opacity-*` → Use `text-black/50` instead + - And others: `border-opacity-*`, `divide-opacity-*`, etc. + +- **Renamed utilities**: + - `shadow-sm` → `shadow-xs` (and `shadow` → `shadow-sm`) + - `drop-shadow-sm` → `drop-shadow-xs` (and `drop-shadow` → `drop-shadow-sm`) + - `blur-sm` → `blur-xs` (and `blur` → `blur-sm`) + - `rounded-sm` → `rounded-xs` (and `rounded` → `rounded-sm`) + - `outline-none` → `outline-hidden` (for the old behavior) + +- **Default style changes**: + - Default border color is now `currentColor` (was `gray-200`) + - Default `ring` width is now 1px (was 3px) + - Placeholder text now uses current color at 50% opacity (was `gray-400`) + - Hover styles only apply on devices that support hover (`@media (hover: hover)`) + +- **Syntax changes**: + - CSS variables in arbitrary values: `bg-(--brand-color)` instead of `bg-[--brand-color]` + - Stacked variants now apply left-to-right (not right-to-left) + - Use CSS variables instead of `theme()` function + +## Advanced Configuration + +- **Using a prefix**: + ```css + @import "tailwindcss" prefix(tw); + ``` + - Results in classes like `tw:flex`, `tw:bg-red-500`, `tw:hover:bg-red-600` + +- **Source detection**: + - Automatic by default (ignores `.gitignore` files and binary files) + - Add sources: `@source "../node_modules/@my-company/ui-lib";` + - Disable automatic detection: `@import "tailwindcss" source(none);` + +- **Legacy config files**: + ```css + @import "tailwindcss"; + @config "../../tailwind.config.js"; + ``` + +- **Dark mode configuration**: + ```css + @import "tailwindcss"; + @variant dark (&:where(.dark, .dark *)); + ``` + +- **Container customization**: Extend with `@utility` + ```css + @utility container { + margin-inline: auto; + padding-inline: 2rem; + } + ``` + +- **Using `@apply` in Vue/Svelte**: + ```html + + ``` \ No newline at end of file diff --git a/AGENT.md b/AGENT.md index 86fa16eaf3..68e20f816a 100644 --- a/AGENT.md +++ b/AGENT.md @@ -11,7 +11,6 @@ This is a pnpm workspace monorepo with the following structure: - `packages/cli/` - CLI tools (`nizzy` command) - `packages/db/` - Database schemas and utilities - `packages/eslint-config/` - Shared ESLint configuration -- `packages/tailwind-config/` - Shared Tailwind configuration - `packages/tsconfig/` - Shared TypeScript configuration ## Frequently Used Commands diff --git a/apps/mail/app/(auth)/login/login-client.tsx b/apps/mail/app/(auth)/login/login-client.tsx index eadf4f07ea..fb770b5e0d 100644 --- a/apps/mail/app/(auth)/login/login-client.tsx +++ b/apps/mail/app/(auth)/login/login-client.tsx @@ -134,7 +134,7 @@ function LoginClientContent({ providers, isProd }: LoginClientProps) { return (
-
+

Login to Zero

@@ -203,7 +203,7 @@ function LoginClientContent({ providers, isProd }: LoginClientProps) {
-
+
{provider.envVarStatus.map((envVar) => (
-
+
@@ -248,7 +248,7 @@ export default function GeneralPage() { name="defaultEmailAlias" render={({ field }) => ( - + {m['pages.settings.general.defaultEmailAlias']()}{' '} diff --git a/apps/mail/app/(routes)/settings/labels/page.tsx b/apps/mail/app/(routes)/settings/labels/page.tsx index e0e4a90676..189a162479 100644 --- a/apps/mail/app/(routes)/settings/labels/page.tsx +++ b/apps/mail/app/(routes)/settings/labels/page.tsx @@ -123,7 +123,7 @@ export default function LabelsPage() { {label.name}
-
+
diff --git a/apps/mail/components/context/loading-context.tsx b/apps/mail/components/context/loading-context.tsx index 9d26669c8d..e8cc674bfb 100644 --- a/apps/mail/components/context/loading-context.tsx +++ b/apps/mail/components/context/loading-context.tsx @@ -22,7 +22,7 @@ export function LoadingProvider({ children }: { children: ReactNode }) { {children} {isLoading && ( -
+
diff --git a/apps/mail/components/context/sidebar-context.tsx b/apps/mail/components/context/sidebar-context.tsx index 967505ac08..c23ee1cd48 100644 --- a/apps/mail/components/context/sidebar-context.tsx +++ b/apps/mail/components/context/sidebar-context.tsx @@ -113,7 +113,7 @@ export const SidebarProvider = React.forwardRef< } as React.CSSProperties } className={cn( - 'group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full', + 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className, )} ref={ref} diff --git a/apps/mail/components/create/ai-chat.tsx b/apps/mail/components/create/ai-chat.tsx index e0c7a4e352..9628b5e805 100644 --- a/apps/mail/components/create/ai-chat.tsx +++ b/apps/mail/components/create/ai-chat.tsx @@ -87,7 +87,7 @@ const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => voi ))} @@ -100,7 +100,7 @@ const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => voi @@ -108,9 +108,9 @@ const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => voi
{/* Left mask */} -
+
{/* Right mask */} -
+
); }; @@ -365,7 +365,7 @@ export function AIChat({
{/* Fixed input at bottom */} -
+
diff --git a/apps/mail/components/create/ai-textarea.tsx b/apps/mail/components/create/ai-textarea.tsx index 8a118fd8a1..b541ac0b24 100644 --- a/apps/mail/components/create/ai-textarea.tsx +++ b/apps/mail/components/create/ai-textarea.tsx @@ -9,7 +9,7 @@ const AITextarea = React.forwardRef( - + {aliases.map((alias) => (
@@ -1359,7 +1359,7 @@ export function EmailComposer({ @@ -1397,7 +1397,7 @@ export function EmailComposer({ className="group flex items-center justify-between gap-3 rounded-md px-1.5 py-1.5 hover:bg-black/5 dark:hover:bg-white/10" >
-
+
{file.type.startsWith('image/') ? ( {truncatedName} {extension && ( - + .{extension} )} @@ -1448,7 +1448,7 @@ export function EmailComposer({ toast.error('Failed to remove attachment'); } }} - className="focus-visible:ring-ring ml-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-transparent hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2" + className="focus-visible:ring-ring ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-transparent hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2" aria-label={`Remove ${file.name}`} > @@ -1566,7 +1566,7 @@ export function EmailComposer({
- + Discard message? @@ -1586,7 +1586,7 @@ export function EmailComposer({ - + Attachment Warning diff --git a/apps/mail/components/create/prosemirror.css b/apps/mail/components/create/prosemirror.css index 3b7702a294..ec5065f85a 100644 --- a/apps/mail/components/create/prosemirror.css +++ b/apps/mail/components/create/prosemirror.css @@ -69,23 +69,23 @@ ul[data-type='taskList'] li > label { ul[data-type='taskList'] li > label input[type='checkbox'] { -webkit-appearance: none; appearance: none; - background-color: hsl(var(--background)); + background-color: var(--background); cursor: pointer; width: 1.2em; height: 1.2em; position: relative; top: 5px; - border: 2px solid hsl(var(--border)); + border: 2px solid var(--border); margin-right: 0.3rem; display: grid; place-content: center; &:hover { - background-color: hsl(var(--accent)); + background-color: var(--accent); } &:active { - background-color: hsl(var(--accent)); + background-color: var(--accent); } &::before { diff --git a/apps/mail/components/create/template-button.tsx b/apps/mail/components/create/template-button.tsx index f5f611960f..55bb5f861c 100644 --- a/apps/mail/components/create/template-button.tsx +++ b/apps/mail/components/create/template-button.tsx @@ -161,7 +161,7 @@ const TemplateButtonComponent: React.FC = ({ Templates - + { setMenuOpen(false); @@ -176,7 +176,7 @@ const TemplateButtonComponent: React.FC = ({ Use template - +
-
+
now
-
+
@@ -374,7 +374,7 @@ export default function HomeContent() {
-
+
- +
March 25 - March 29
@@ -721,7 +721,7 @@ export default function HomeContent() {
-
+
@@ -1179,7 +1179,7 @@ export default function HomeContent() {
-
+
@@ -1220,7 +1220,7 @@ export default function HomeContent() { {firstRowQueries.map((query) => (
@@ -1230,8 +1230,8 @@ export default function HomeContent() {
))}
-
-
+
+
{/* Second row */} @@ -1240,7 +1240,7 @@ export default function HomeContent() { {secondRowQueries.map((query) => (
@@ -1250,8 +1250,8 @@ export default function HomeContent() {
))}
-
-
+
+
@@ -1261,7 +1261,7 @@ export default function HomeContent() { Ask Zero to do anything...
- +
diff --git a/apps/mail/components/home/footer.tsx b/apps/mail/components/home/footer.tsx index 1b056250b4..68745baa77 100644 --- a/apps/mail/components/home/footer.tsx +++ b/apps/mail/components/home/footer.tsx @@ -28,7 +28,7 @@ export default function Footer() { return (
- {/*
*/} + {/*
*/}
Experience the Future of
Email Today diff --git a/apps/mail/components/magicui/file-tree.tsx b/apps/mail/components/magicui/file-tree.tsx index 4d061e41c1..98606f0a4c 100644 --- a/apps/mail/components/magicui/file-tree.tsx +++ b/apps/mail/components/magicui/file-tree.tsx @@ -247,7 +247,7 @@ const Folder = ({ > {canExpand ? ( { e.stopPropagation(); diff --git a/apps/mail/components/mail/attachment-dialog.tsx b/apps/mail/components/mail/attachment-dialog.tsx index 0ae6f9174b..7828251320 100644 --- a/apps/mail/components/mail/attachment-dialog.tsx +++ b/apps/mail/components/mail/attachment-dialog.tsx @@ -25,7 +25,7 @@ const AttachmentDialog = ({ selectedAttachment, setSelectedAttachment }: Props) open={!!selectedAttachment} onOpenChange={(open) => !open && setSelectedAttachment(null)} > - + {selectedAttachment?.name} diff --git a/apps/mail/components/mail/attachments-accordion.tsx b/apps/mail/components/mail/attachments-accordion.tsx index eb24de966c..60517b22fd 100644 --- a/apps/mail/components/mail/attachments-accordion.tsx +++ b/apps/mail/components/mail/attachments-accordion.tsx @@ -33,7 +33,7 @@ const AttachmentsAccordion = ({ attachments, setSelectedAttachment }: Props) => return (
diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index e6910b1332..7f82fd9686 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -133,7 +133,7 @@ const StreamingText = ({ text }: { text: string }) => {
@@ -1481,7 +1481,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
-
+
@@ -1521,7 +1521,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: disabled={!messageAttachments?.length} className={ !messageAttachments?.length - ? 'data-[disabled]:pointer-events-auto' + ? 'data-disabled:pointer-events-auto' : '' } onClick={(e) => { @@ -1645,7 +1645,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
@@ -1693,7 +1693,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: {index < (messageAttachments?.length || 0) - 1 && ( -
+
)}
))} diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index e19a41c719..ced2721e96 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -239,7 +239,7 @@ const Thread = memo( >
diff --git a/apps/mail/components/mail/mail-skeleton.tsx b/apps/mail/components/mail/mail-skeleton.tsx index 85fbc9a3b9..62d4140ce4 100644 --- a/apps/mail/components/mail/mail-skeleton.tsx +++ b/apps/mail/components/mail/mail-skeleton.tsx @@ -31,7 +31,7 @@ export const MailDisplaySkeleton = ({ isFullscreen }: { isFullscreen?: boolean }
- +
@@ -72,7 +72,7 @@ export const MailDisplaySkeleton = ({ isFullscreen }: { isFullscreen?: boolean }
- +
@@ -113,7 +113,7 @@ export const MailDisplaySkeleton = ({ isFullscreen }: { isFullscreen?: boolean }
- +
diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 15edc1b1b0..7b563c4179 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -461,7 +461,7 @@ export function MailLayout() { return ( -
+
@@ -528,16 +528,16 @@ export function MailLayout() { Clear )} - + {isMac ? '⌘' : 'Ctrl'}{' '} - K + K @@ -582,11 +582,11 @@ export function MailLayout() {
-
+
diff --git a/apps/mail/components/mail/navbar.tsx b/apps/mail/components/mail/navbar.tsx index acb13f3b55..4c86995e8b 100644 --- a/apps/mail/components/mail/navbar.tsx +++ b/apps/mail/components/mail/navbar.tsx @@ -19,7 +19,7 @@ export function Nav({ links, isCollapsed }: NavProps) { data-collapsed={isCollapsed} className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2" > -
{/* Left mask */} -
+
{/* Right mask */} -
+
); }; -interface Message { - id: string; - role: 'user' | 'assistant' | 'data' | 'system'; - parts: Array<{ - type: string; - text?: string; - toolInvocation?: { - toolName: string; - result?: { - threads?: Array<{ id: string; title: string; snippet: string }>; - }; - args?: any; - }; - }>; -} +// interface Message { +// id: string; +// role: 'user' | 'assistant' | 'data' | 'system'; +// parts: Array<{ +// type: string; +// text?: string; +// toolInvocation?: { +// toolName: string; +// result?: { +// threads?: Array<{ id: string; title: string; snippet: string }>; +// }; +// args?: any; +// }; +// }>; +// } export interface AIChatProps { - messages: Message[]; + messages: AiMessage[]; input: string; setInput: (input: string) => void; error?: Error; @@ -141,6 +143,7 @@ export interface AIChatProps { stop: () => void; className?: string; onModelChange?: (model: string) => void; + setMessages: (messages: AiMessage[]) => void; } // Subcomponents for ToolResponse @@ -198,7 +201,8 @@ export function AIChat({ error, handleSubmit, status, -}: AIChatProps): React.ReactElement { + append, +}: ReturnType): React.ReactElement { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const { chatMessages } = useBilling(); @@ -206,6 +210,7 @@ export function AIChat({ const [, setPricingDialog] = useQueryState('pricingDialog'); const [aiSidebarOpen] = useQueryState('aiSidebar'); const { toggleOpen } = useAISidebar(); + const [threadId] = useQueryState('threadId'); const scrollToBottom = useCallback(() => { if (messagesEndRef.current) { @@ -235,6 +240,11 @@ export function AIChat({ const onSubmit = (e: React.FormEvent) => { e.preventDefault(); + append({ + id: crypto.randomUUID(), + role: 'system', + content: `The user is on thread: ${threadId}`, + }); handleSubmit(e); editor.commands.clearContent(true); setTimeout(() => { @@ -260,13 +270,13 @@ export function AIChat({
{chatMessages && !chatMessages.enabled ? (
setPricingDialog('true')} - className="absolute inset-0 flex flex-col items-center justify-center" - > - - Upgrade to Zero Pro for unlimited AI chat - - + onClick={() => setPricingDialog('true')} + className="absolute inset-0 flex flex-col items-center justify-center" + > + + Upgrade to Zero Pro for unlimited AI chat + +
) : !messages.length ? (
@@ -286,6 +296,7 @@ export function AIChat({
) : ( messages.map((message, index) => { + if (message.role === 'system') return null; const textParts = message.parts.filter((part) => part.type === 'text'); const toolParts = message.parts.filter((part) => part.type === 'tool-invocation'); @@ -402,7 +413,7 @@ export function AIChat({
- {/*
+ {/*