From 898a10d2d2c01f69893dd7e3c418bb8aaa6426d6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 31 Jul 2026 12:27:16 -0700 Subject: [PATCH 01/19] improvement(files): match CSV/XLSX preview tables to the markdown table chrome (#6125) * improvement(files): match CSV/XLSX preview tables to the markdown table chrome Tables in the file viewer looked different depending on the file: CSV and XLSX previews rendered their own chrome (rounded outer frame, --surface-2 header, 13px body / 12px header, --text-secondary cells) while markdown files rendered tables through the rich markdown editor (full cell borders on --divider, --surface-4 header, 14px text). Extract the markdown table chrome into document-table.css and style both surfaces from it. The editor stylesheet keeps only its own concerns (fixed layout for column resizing, prose block margin, cell paragraph reset); DataTable keeps only its edit affordances. * fix(files): wrap unbreakable cell values in preview tables like markdown does The markdown prose root sets overflow-wrap: anywhere; the preview root did not, so with whitespace-nowrap gone a long URL or hash in a CSV cell would overflow instead of breaking. --- .../components/file-viewer/data-table.tsx | 24 +-- .../components/file-viewer/document-table.css | 46 ++++++ .../file-viewer/document-table.test.ts | 155 ++++++++++++++++++ .../rich-markdown-editor.css | 22 +-- .../rich-markdown-editor.tsx | 1 + .../rich-markdown-field.tsx | 1 + 6 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx index 12a3d72a794..2ba7a2e3131 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx @@ -2,6 +2,7 @@ import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react' import { cn } from '@sim/emcn' +import './document-table.css' interface EditConfig { onCellChange: (row: number, col: number, value: string) => void @@ -20,6 +21,11 @@ export interface DataTableHandle { type EditingCell = { row: number; col: number } | null +/** + * Tabular renderer for CSV and XLSX previews. Chrome (borders, padding, typography, header fill) + * comes entirely from `document-table.css`, the definition shared with markdown tables in the rich + * markdown editor — the only classes here are the optional edit affordances. + */ const DataTableBase = forwardRef(function DataTable( { headers, rows, editConfig }, ref @@ -94,16 +100,15 @@ const DataTableBase = forwardRef(function DataT editingCell?.row === row && editingCell?.col === col return ( -
- - +
+
+ {headers.map((header, i) => ( {rows.map((row, ri) => ( - + {headers.map((_, ci) => (
editConfig && startEdit(-1, i, String(header ?? ''))} > @@ -114,7 +119,7 @@ const DataTableBase = forwardRef(function DataT onChange={(e) => setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={handleKeyDown} - className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' + className='w-full min-w-[60px] bg-transparent outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' /> ) : ( String(header ?? '') @@ -125,13 +130,12 @@ const DataTableBase = forwardRef(function DataT
editConfig && startEdit(ri, ci, String(row[ci] ?? ''))} > @@ -142,7 +146,7 @@ const DataTableBase = forwardRef(function DataT onChange={(e) => setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={handleKeyDown} - className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' + className='w-full min-w-[60px] bg-transparent outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' /> ) : ( String(row[ci] ?? '') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css new file mode 100644 index 00000000000..6a73506634b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css @@ -0,0 +1,46 @@ +/* + * Canonical table chrome for the file viewer. Both surfaces that render a table for a file — the + * rich markdown editor (`.rich-markdown-prose table`) and the tabular previews CSV/XLSX render + * through `DataTable` (`.document-table`) — share this one definition so a table looks the same + * whichever file it came from. Editor-only concerns (prose block margin, fixed layout for column + * resizing, cell paragraph reset) stay in rich-markdown-editor.css. + */ + +/* `overflow-wrap` matches what `.rich-markdown-prose` sets on its own root: cells hold arbitrary + file data, so an unbreakable token (a URL, a hash) must break rather than overflow its cell. */ +.document-table { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.rich-markdown-prose table, +.document-table table { + width: 100%; + border-collapse: collapse; + overflow: hidden; +} + +.rich-markdown-prose th, +.rich-markdown-prose td, +.document-table th, +.document-table td { + position: relative; + border: 1px solid var(--divider); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; + font-size: 14px; + line-height: 1.5rem; +} + +.rich-markdown-prose th, +.document-table th { + background: var(--surface-4); + font-weight: 600; +} + +/* Cell editors are invisible until focused: they take the cell's own typography and color. */ +.document-table input { + font: inherit; + color: inherit; +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts new file mode 100644 index 00000000000..753d4f5c343 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts @@ -0,0 +1,155 @@ +/** + * @vitest-environment jsdom + * + * A table must look the same whichever file it came from: a markdown file rendered by the rich + * markdown editor (`.rich-markdown-prose table`) and a CSV/XLSX preview rendered by `DataTable` + * (`.document-table`) sit in the same file viewer, in the same session, one click apart. They used + * to drift — the previews carried their own chrome (rounded outer frame, `--surface-2` header, + * 13px body / 12px header, `--text-secondary` cells) while markdown tables used full cell borders + * on `--divider`, a `--surface-4` header, and 14px text. + * + * These load the real, shipped CSS (not a copy). Two complementary assertions, because jsdom's CSS + * engine resolves only part of what matters here: it applies the cascade for longhand declarations + * (so `getComputedStyle` parity is a real check on padding/typography/fill), but it does not expand + * the `border` shorthand at all. Borders are therefore checked structurally — one rule, whose + * selector list covers both roots — which is also the property that actually prevents drift. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' + +const SHARED_CSS_PATH = path.join(__dirname, 'document-table.css') +const EDITOR_CSS_PATH = path.join(__dirname, 'rich-markdown-editor', 'rich-markdown-editor.css') + +/** Chrome both surfaces must agree on, as longhands jsdom resolves. */ +const SHARED_CELL_PROPS = [ + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'font-size', + 'line-height', + 'text-align', + 'vertical-align', +] as const + +const sheets = new Map() + +beforeAll(() => { + for (const file of [SHARED_CSS_PATH, EDITOR_CSS_PATH]) { + const style = document.createElement('style') + style.textContent = readFileSync(file, 'utf-8') + document.head.appendChild(style) + if (!style.sheet) throw new Error(`stylesheet did not parse: ${file}`) + sheets.set(file, style.sheet) + } +}) + +let containers: HTMLDivElement[] = [] + +afterEach(() => { + for (const c of containers) c.remove() + containers = [] +}) + +/** Mounts a one-cell table inside `rootClass` and returns the root plus its `th` and `td`. */ +function mountTable(rootClass: string): { root: HTMLElement; th: HTMLElement; td: HTMLElement } { + const container = document.createElement('div') + container.className = rootClass + container.innerHTML = + '
h
c
' + document.body.appendChild(container) + containers.push(container) + const th = container.querySelector('th') + const td = container.querySelector('td') + if (!th || !td) throw new Error('table cells not found') + return { root: container, th, td } +} + +function declarations(el: Element, props: readonly string[]): Record { + const computed = getComputedStyle(el) + return Object.fromEntries(props.map((p) => [p, computed.getPropertyValue(p)])) +} + +/** Style rules of one loaded stylesheet, in source order. */ +function styleRules(cssPath: string): CSSStyleRule[] { + const sheet = sheets.get(cssPath) + if (!sheet) throw new Error(`stylesheet not loaded: ${cssPath}`) + return Array.from(sheet.cssRules).filter((r): r is CSSStyleRule => r instanceof CSSStyleRule) +} + +/** Selector list of the single shared rule declaring `property: value`, as trimmed selectors. */ +function selectorsDeclaring(cssPath: string, property: string, value: string): string[] { + const matching = styleRules(cssPath).filter((r) => + r.style.getPropertyValue(property).includes(value) + ) + expect(matching).toHaveLength(1) + return matching[0].selectorText.split(',').map((s) => s.trim()) +} + +describe('document-table chrome is shared with markdown tables', () => { + it('cells resolve to identical padding and typography', () => { + const prose = mountTable('rich-markdown-prose') + const preview = mountTable('document-table') + + expect(declarations(preview.td, SHARED_CELL_PROPS)).toEqual( + declarations(prose.td, SHARED_CELL_PROPS) + ) + expect(declarations(preview.th, SHARED_CELL_PROPS)).toEqual( + declarations(prose.th, SHARED_CELL_PROPS) + ) + }) + + /** + * Cells hold arbitrary file data, so an unbreakable token (a URL, a hash) must break rather than + * overflow — the previews lost `whitespace-nowrap` and would otherwise have no wrapping rule at + * all. `overflow-wrap` is inherited from each surface's root (jsdom does not propagate inherited + * properties to descendants, so the roots are what can be asserted). + */ + it('both roots declare the same wrapping for unbreakable cell values', () => { + const prose = mountTable('rich-markdown-prose') + const preview = mountTable('document-table') + + const wrap = getComputedStyle(prose.root).getPropertyValue('overflow-wrap') + expect(wrap).toBe('anywhere') + expect(getComputedStyle(preview.root).getPropertyValue('overflow-wrap')).toBe(wrap) + }) + + it('the resolved values are the markdown editor values, not jsdom defaults', () => { + const { th, td } = mountTable('document-table') + + expect(getComputedStyle(td).getPropertyValue('padding-left')).toBe('0.75rem') + expect(getComputedStyle(td).getPropertyValue('font-size')).toBe('14px') + expect(getComputedStyle(th).getPropertyValue('font-weight')).toBe('600') + }) + + it('one rule draws the cell border for both roots', () => { + expect(selectorsDeclaring(SHARED_CSS_PATH, 'border', 'var(--divider)')).toEqual( + expect.arrayContaining([ + '.rich-markdown-prose th', + '.rich-markdown-prose td', + '.document-table th', + '.document-table td', + ]) + ) + }) + + it('one rule fills the header for both roots', () => { + expect(selectorsDeclaring(SHARED_CSS_PATH, 'background', 'var(--surface-4)')).toEqual( + expect.arrayContaining(['.rich-markdown-prose th', '.document-table th']) + ) + }) + + it('the editor stylesheet no longer re-declares cell chrome of its own', () => { + const redeclared = styleRules(EDITOR_CSS_PATH).filter( + (r) => + /\.rich-markdown-prose (th|td)\b/.test(r.selectorText) && + (r.style.getPropertyValue('border') || + r.style.getPropertyValue('padding') || + r.style.getPropertyValue('font-size') || + r.style.getPropertyValue('background')) + ) + + expect(redeclared.map((r) => r.selectorText)).toEqual([]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index cfb5e9a2ec3..bef319d0574 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -355,28 +355,12 @@ border-radius: 4px; } +/* Borders, padding, typography, and header fill come from document-table.css — the chrome shared + with the CSV/XLSX previews. Only the editor-specific bits live here: `table-layout: fixed` is + required by prosemirror-tables' column-resizing plugin, and the block margin is prose rhythm. */ .rich-markdown-prose table { - width: 100%; - border-collapse: collapse; table-layout: fixed; margin: 1rem 0; - overflow: hidden; -} - -.rich-markdown-prose th, -.rich-markdown-prose td { - position: relative; - border: 1px solid var(--divider); - padding: 0.5rem 0.75rem; - text-align: left; - vertical-align: top; - font-size: 14px; - line-height: 1.5rem; -} - -.rich-markdown-prose th { - background: var(--surface-4); - font-weight: 600; } .rich-markdown-prose th > p, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9107dbd795d..34bad2985ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -39,6 +39,7 @@ import { TableBubbleMenu } from './menus/table-menu' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@sim/emcn/components/code/code.css' +import '../document-table.css' import './rich-markdown-editor.css' const EXTENSIONS = createMarkdownEditorExtensions({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index e0723b4d5ca..a499d54b9fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -17,6 +17,7 @@ import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@sim/emcn/components/code/code.css' +import '../document-table.css' import './rich-markdown-editor.css' interface RichMarkdownFieldProps { From ed23330f884d7a10d419cd0d4fea10d1191560ca Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 31 Jul 2026 12:42:09 -0700 Subject: [PATCH 02/19] feat(knowledge): opt-in hybrid lexical + vector retrieval for KB search (#6124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(knowledge): hybrid lexical + vector retrieval for KB search KB search ranked purely on pgvector cosine distance, which retrieves exact tokens (error codes, ticket keys, identifiers, rare product names) poorly. Add a full-text leg over the already-present generated `embedding.content_tsv` column and its GIN index — no migration, no re-indexing — and fuse it with the vector leg by reciprocal rank. Both legs run concurrently and share the same visibility and tag-filter predicates; the lexical leg is best-effort and falls back to vector-only on failure. Hybrid is the default for every caller. `searchMode: 'vector'` on the internal and v1 contracts (and an advanced Retrieval Mode dropdown on the Knowledge block) restores the previous behavior. Both search routes now share one `executeKnowledgeSearch` dispatch instead of duplicating the three-branch retrieval logic. * change(knowledge): make vector the default search mode, hybrid opt-in Every existing caller — workflow block, v1 API, copilot, guardrail RAG — keeps its current ranking. Hybrid retrieval is now requested explicitly via `searchMode: 'hybrid'`. Also routes the copilot knowledge tool through the shared `executeKnowledgeSearch` dispatch so all four callers share one retrieval path, and documents `searchMode` on the public v1 search endpoint in the OpenAPI spec. * docs(knowledge): document the hybrid retrieval mode Regenerates the knowledge integration reference for the new searchMode tool param, and adds a Retrieval Mode section to the knowledge base workflow guide explaining when hybrid beats vector-only. * fix(knowledge): stop rank fusion from starving the lexical leg Rank n in one leg always ties rank n in the other, so ordering the fused list by score alone let whichever leg was scored first take every tied slot. At topK=1 that meant a hybrid search returned exactly the vector-only result and discarded the exact keyword match the mode exists to recover. Selection now orders by score and drains each tie group round-robin, taking from whichever leg has contributed fewest rows so far. The lexical leg is passed first so it wins a total tie, since a chunk the vector leg ranked below its distance threshold is the case hybrid was opted into for. * fix(knowledge): credit a shared hit to every leg that returned it Attributing a row found by both legs to a single leg left the round-robin owing the other leg a slot it had already been served. With a shared rank-1 hit and topK 2, that evicted the lexical-only row — the exact match hybrid was enabled to recover — in favor of the vector-only one. A shared row satisfied every leg that returned it, so every one of them is now charged for it. Tie-breaking prefers the candidate whose least-served leg has been served least, which also removes the arbitrary best-rank attribution. * fix(knowledge): reject a whitespace-only copilot query explicitly The shared dispatch treats a whitespace-only query as absent and throws when no tag filters accompany it, where the previous vector-only call would have embedded the blank string and searched. Tighten the existing guard so the tool returns its normal message instead. * fix(knowledge): fan the keyword leg out per knowledge base The vector leg caps candidates per base once getQueryStrategy sets useParallel, but the keyword leg always ran one global query with a single LIMIT. Searching several bases at once let whichever one ranks strongest lexically consume every slot, so an exact-token hit in a smaller base never reached fusion — the case hybrid exists to serve. The keyword leg now uses the same strategy: per-base queries under the same parallel limit, re-ranked globally on a selected ts_rank_cd. Both legs draw candidates the same way, so fusion combines rankings over the same pool. * perf(knowledge): stop the keyword leg detoasting every match's vector Selecting the cosine distance in the ranking query made Postgres detoast the 1536-dimension embedding and compute a distance for every full-text match before the LIMIT applied, so cost tracked how common the query term was rather than topK. On a 20k-chunk base with a term matching every row that was 61,055 buffer hits against 1,030 for the same query without the projection. Rank on ids and ts_rank_cd alone, then hydrate only the rows that survive the limit. Same results, and the worst case drops to ~27ms end to end. --- .../docs/en/integrations/knowledge.mdx | 1 + .../en/knowledgebase/using-in-workflows.mdx | 14 + apps/docs/openapi.json | 11 +- .../app/api/knowledge/search/route.test.ts | 124 +++--- apps/sim/app/api/knowledge/search/route.ts | 33 +- .../app/api/knowledge/search/utils.test.ts | 358 +++++++++++++++++- apps/sim/app/api/knowledge/search/utils.ts | 300 ++++++++++++++- .../app/api/v1/knowledge/search/route.test.ts | 22 +- apps/sim/app/api/v1/knowledge/search/route.ts | 33 +- apps/sim/blocks/blocks/knowledge.ts | 16 + .../sim/lib/api/contracts/knowledge/search.ts | 19 + .../lib/api/contracts/v1/knowledge/index.ts | 6 + .../server/knowledge/knowledge-base.test.ts | 3 +- .../tools/server/knowledge/knowledge-base.ts | 11 +- apps/sim/tools/knowledge/search.ts | 8 + packages/testing/src/mocks/database.mock.ts | 15 +- 16 files changed, 834 insertions(+), 140 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/knowledge.mdx b/apps/docs/content/docs/en/integrations/knowledge.mdx index d5493b4bb8c..42ebb1b7eb3 100644 --- a/apps/docs/content/docs/en/integrations/knowledge.mdx +++ b/apps/docs/content/docs/en/integrations/knowledge.mdx @@ -43,6 +43,7 @@ Search for similar content in a knowledge base using vector similarity | `query` | string | No | Search query text \(optional when using tag filters\) | | `topK` | number | No | Number of most similar results to return \(1-100\) | | `tagFilters` | array | No | Array of tag filters with tagName and tagValue properties | +| `searchMode` | string | No | Retrieval mode: 'vector' \(default\) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both | | `rerankerEnabled` | boolean | No | Whether to apply Cohere reranking to vector search results | | `rerankerModel` | string | No | Cohere rerank model to use \(one of: rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5\) | | `rerankerInputCount` | number | No | Number of vector results sent to the Cohere reranker \(1–100\). Defaults to topK × 4 capped at 100. | diff --git a/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx b/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx index 00ffae3f3ab..be9bb00d462 100644 --- a/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx +++ b/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx @@ -37,6 +37,19 @@ In our example, adding `Department equals "Billing"` makes the search consider o Filters run before the vector comparison, so they make a search both more precise and cheaper. See [Tags and filtering](/knowledgebase/tags) for the full operator list by tag type. +## Retrieval Mode + +**Retrieval Mode** is an advanced setting that chooses how matches are found. + +| Mode | What it does | +| --- | --- | +| Vector only | The default. Ranks purely on meaning, as described above. | +| Hybrid | Also runs a keyword search over the same chunks and blends the two rankings. | + +Semantic search is strong on paraphrase and weak on literal strings: an error code, a ticket key like `PROJ-1234`, a SKU, or a rare product name carries little meaning for the model, so the chunk containing it may not rank near the top. Hybrid adds a keyword pass that matches those tokens exactly, then merges the two lists so a chunk found by either signal can surface. + +Turn it on when your documents are full of identifiers, codes, or names people search for verbatim. Leave it off for prose-heavy bases where questions are asked in natural language. Hybrid costs no extra API calls — the keyword pass runs entirely in the database. + ## Rerank Results **Rerank Results** is an optional second pass. Vector search ranks by raw similarity; reranking re-scores the top matches with a dedicated relevance model (Cohere's rerank models) and reorders them, which sharpens the ordering when the best answer isn't the literal closest vector. @@ -95,6 +108,7 @@ When the agent's answer is off, the cause is usually in retrieval, not the agent - **No results, or wrong documents.** A tag filter may be excluding what you want, or the documents may not be indexed yet. A document is only searchable once its processing status is `completed`; while it is `pending`, `processing`, or `failed`, its chunks won't appear. - **Low similarity scores across the board.** The query is too vague, or the information simply isn't in the base. Rewrite the query to match how the documents phrase things. - **Right documents, wrong order.** Turn on Rerank Results, or raise Number of Results so the relevant chunk is included. +- **An exact code, ID, or name isn't found.** Switch Retrieval Mode to Hybrid so a keyword pass runs alongside the semantic one. See [debugging retrieval](/knowledgebase/debugging-retrieval) for the full diagnostic path, and [chunking strategies](/knowledgebase/chunking-strategies) for how chunk boundaries shape what a search can return. diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 4967b8c8186..b2e8ca4c523 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -6040,7 +6040,7 @@ "post": { "operationId": "searchKnowledgeBase", "summary": "Search Knowledge Base", - "description": "Perform vector similarity search across one or more knowledge bases. Supports semantic search via query text, tag-based filtering, or a combination of both.", + "description": "Search across one or more knowledge bases. Supports semantic search via query text, tag-based filtering, or a combination of both. Set `searchMode` to `hybrid` to additionally run a full-text keyword leg and fuse it with the semantic results.", "tags": ["Knowledge Bases"], "x-codeSamples": [ { @@ -6095,6 +6095,12 @@ "items": { "$ref": "#/components/schemas/TagFilter" } + }, + "searchMode": { + "type": "string", + "enum": ["vector", "hybrid"], + "default": "vector", + "description": "Retrieval strategy. `vector` ranks purely on embedding similarity. `hybrid` also runs a full-text keyword search and fuses the two rankings by reciprocal rank, which retrieves exact tokens — error codes, ticket keys, identifiers, rare product names — that embeddings alone rank poorly. Ignored when only tagFilters are provided." } } }, @@ -6102,7 +6108,8 @@ "workspaceId": "wsp_abc123", "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "query": "How do I reset my password?", - "topK": 5 + "topK": 5, + "searchMode": "hybrid" } } } diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 88e379dafe7..d12fd48e0aa 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -21,18 +21,12 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites const { mockGetDocumentTagDefinitions, - mockHandleTagOnlySearch, - mockHandleVectorOnlySearch, - mockHandleTagAndVectorSearch, - mockGetQueryStrategy, + mockExecuteKnowledgeSearch, mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, } = vi.hoisted(() => ({ mockGetDocumentTagDefinitions: vi.fn(), - mockHandleTagOnlySearch: vi.fn(), - mockHandleVectorOnlySearch: vi.fn(), - mockHandleTagAndVectorSearch: vi.fn(), - mockGetQueryStrategy: vi.fn(), + mockExecuteKnowledgeSearch: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), mockGetDocumentMetadataByIds: vi.fn(), })) @@ -69,10 +63,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ })) vi.mock('./utils', () => ({ - handleTagOnlySearch: mockHandleTagOnlySearch, - handleVectorOnlySearch: mockHandleVectorOnlySearch, - handleTagAndVectorSearch: mockHandleTagAndVectorSearch, - getQueryStrategy: mockGetQueryStrategy, + executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, APIError: class APIError extends Error { @@ -118,15 +109,7 @@ describe('Knowledge Search API Route', () => { resetDbChainMock() setEnv({ OPENAI_API_KEY: 'test-api-key' }) - mockHandleTagOnlySearch.mockClear() - mockHandleVectorOnlySearch.mockClear() - mockHandleTagAndVectorSearch.mockClear() - mockGetQueryStrategy.mockClear().mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) + mockExecuteKnowledgeSearch.mockClear() mockGenerateSearchEmbedding .mockClear() .mockResolvedValue({ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], isBYOK: false }) @@ -192,7 +175,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValue([]) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -212,14 +195,50 @@ describe('Knowledge Search API Route', () => { expect(data.data.results[0].similarity).toBe(0.8) // 1 - 0.2 expect(data.data.query).toBe(validSearchData.query) expect(data.data.knowledgeBaseIds).toEqual(['kb-123']) - expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, + searchMode: 'vector', + query: validSearchData.query, queryVector: JSON.stringify(mockEmbedding), - distanceThreshold: expect.any(Number), + structuredFilters: undefined, }) }) + it('should forward the hybrid searchMode opt-in to the retrieval layer', async () => { + mockGetUserId.mockResolvedValue('user-123') + + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + }, + }) + + dbChainMockFns.limit.mockResolvedValue([]) + + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: [{ embedding: mockEmbedding }], + }), + }) + + const req = createMockRequest('POST', { ...validSearchData, searchMode: 'hybrid' }) + const response = await POST(req) + + expect(response.status).toBe(200) + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith( + expect.objectContaining({ searchMode: 'hybrid' }) + ) + }) + it('should perform search successfully with multiple knowledge bases', async () => { const multiKbData = { ...validSearchData, @@ -239,7 +258,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValue([]) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -256,11 +275,13 @@ describe('Knowledge Search API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456']) - expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123', 'kb-456'], topK: 10, + searchMode: 'vector', + query: multiKbData.query, queryVector: JSON.stringify(mockEmbedding), - distanceThreshold: expect.any(Number), + structuredFilters: undefined, }) }) @@ -284,7 +305,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValue([]) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -348,7 +369,7 @@ describe('Knowledge Search API Route', () => { embeddingModel: 'text-embedding-3-small', }, }) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) const attribution = encodeURIComponent( JSON.stringify({ actorUserId: 'user-123', @@ -532,7 +553,7 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) - mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error')) + mockExecuteKnowledgeSearch.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('POST', validSearchData) const response = await POST(req) @@ -750,7 +771,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults) const req = createMockRequest('POST', tagOnlyData) const response = await POST(req) @@ -763,9 +784,10 @@ describe('Knowledge Search API Route', () => { expect(data.data.query).toBe('') // Empty query expect(data.data.cost).toBeUndefined() // No cost for tag-only search expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding API call - expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, + searchMode: 'vector', structuredFilters: [ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, ], @@ -796,7 +818,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -816,14 +838,15 @@ describe('Knowledge Search API Route', () => { expect(data.data.query).toBe('test search') expect(data.data.cost).toBeDefined() // Cost included for vector search expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called - expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, + searchMode: 'vector', + query: 'test search', + queryVector: JSON.stringify(mockEmbedding), structuredFilters: [ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, ], - queryVector: JSON.stringify(mockEmbedding), - distanceThreshold: 1, // Single KB uses threshold of 1.0 }) }) @@ -987,7 +1010,7 @@ describe('Knowledge Search API Route', () => { mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults) dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) @@ -1016,7 +1039,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockHandleVectorOnlySearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { id: 'chunk-1', content: 'Content from active document', @@ -1034,13 +1057,6 @@ describe('Knowledge Search API Route', () => { }, ]) - mockGetQueryStrategy.mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active': { @@ -1092,7 +1108,7 @@ describe('Knowledge Search API Route', () => { { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, ]) - mockHandleTagOnlySearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { id: 'chunk-2', content: 'Content from active document with tag', @@ -1110,13 +1126,6 @@ describe('Knowledge Search API Route', () => { }, ]) - mockGetQueryStrategy.mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) - mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active-tagged': { filename: 'Active Tagged Document.pdf', sourceUrl: null }, }) @@ -1164,7 +1173,7 @@ describe('Knowledge Search API Route', () => { { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, ]) - mockHandleTagAndVectorSearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { id: 'chunk-3', content: 'Relevant content from active document', @@ -1182,13 +1191,6 @@ describe('Knowledge Search API Route', () => { }, ]) - mockGetQueryStrategy.mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null }, diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index aa337524f6a..12a88854571 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -27,12 +27,9 @@ import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/ import type { StructuredFilter } from '@/lib/knowledge/types' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { + executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, - getQueryStrategy, - handleTagAndVectorSearch, - handleTagOnlySearch, - handleVectorOnlySearch, type SearchResult, } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' @@ -318,32 +315,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : validatedData.topK if (!hasQuery && hasFilters) { - results = await handleTagOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK: validatedData.topK, + searchMode: validatedData.searchMode, structuredFilters, }) - } else if (hasQuery && hasFilters) { - logger.debug(`[${requestId}] Executing tag + vector search with filters:`, structuredFilters) - const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) - const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) - - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK: candidateTopK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else if (hasQuery && !hasFilters) { - const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) + } else if (hasQuery) { + logger.debug( + `[${requestId}] Executing ${validatedData.searchMode} search`, + hasFilters ? { structuredFilters } : undefined + ) const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) - results = await handleVectorOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK: candidateTopK, + searchMode: validatedData.searchMode, + query: validatedData.query, queryVector, - distanceThreshold: strategy.distanceThreshold, + structuredFilters: hasFilters ? structuredFilters : undefined, }) } else { return NextResponse.json( diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 4a9c5e1f0e5..461289c09d2 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -4,7 +4,14 @@ * * @vitest-environment node */ -import { mockNextFetchResponse, setupGlobalFetchMock } from '@sim/testing/mocks' +import { + dbChainMockFns, + mockNextFetchResponse, + queueTableRows, + resetDbChainMock, + schemaMock, + setupGlobalFetchMock, +} from '@sim/testing/mocks' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env } from '@/lib/core/config/env' import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' @@ -39,12 +46,47 @@ afterEach(() => { }) import { + executeKeywordSearch, + executeKnowledgeSearch, + fuseByReciprocalRank, generateSearchEmbedding, + getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch, + RRF_K, + type SearchResult, } from '@/app/api/knowledge/search/utils' +/** Minimal SearchResult builder — only the fields fusion and ordering read. */ +function makeResult(id: string, distance = 0.1): SearchResult { + return { + id, + content: `content-${id}`, + documentId: `doc-${id}`, + chunkIndex: 0, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, + distance, + knowledgeBaseId: 'kb-123', + } +} + describe('Knowledge Search Utils', () => { beforeEach(() => { vi.clearAllMocks() @@ -183,6 +225,320 @@ describe('Knowledge Search Utils', () => { }) }) + describe('fuseByReciprocalRank', () => { + it('ranks a row found by both legs above rows found by only one', () => { + const shared = makeResult('shared') + const vectorOnly = makeResult('vector-only') + const keywordOnly = makeResult('keyword-only') + + const fused = fuseByReciprocalRank( + [ + [vectorOnly, shared], + [keywordOnly, shared], + ], + 10 + ) + + expect(fused[0].id).toBe('shared') + // `shared` is credited to both legs, so the following tie is even and + // resolves to the earliest list. + expect(fused.map((r) => r.id)).toEqual(['shared', 'vector-only', 'keyword-only']) + }) + + it('dedupes by chunk id, keeping the first occurrence', () => { + const fromVector = makeResult('chunk-1', 0.2) + const fromKeyword = { ...makeResult('chunk-1', 0.9), content: 'stale copy' } + + const fused = fuseByReciprocalRank([[fromVector], [fromKeyword]], 10) + + expect(fused).toHaveLength(1) + expect(fused[0].content).toBe('content-chunk-1') + expect(fused[0].distance).toBe(0.2) + }) + + it('preserves leg ordering when only one leg returns rows', () => { + const rows = [makeResult('a'), makeResult('b'), makeResult('c')] + + expect(fuseByReciprocalRank([rows, []], 10).map((r) => r.id)).toEqual(['a', 'b', 'c']) + expect(fuseByReciprocalRank([[], rows], 10).map((r) => r.id)).toEqual(['a', 'b', 'c']) + }) + + it('scores by reciprocal rank so a deep double hit beats a shallow single hit', () => { + const deepShared = makeResult('deep-shared') + const topSingle = makeResult('top-single') + + /** + * `deep-shared` sits at rank 2 in both legs: 2 / (RRF_K + 2). + * `top-single` sits at rank 1 in one leg only: 1 / (RRF_K + 1). + * With RRF_K = 60 the double hit wins. + */ + expect(2 / (RRF_K + 2)).toBeGreaterThan(1 / (RRF_K + 1)) + + const fused = fuseByReciprocalRank( + [ + [topSingle, deepShared], + [makeResult('other'), deepShared], + ], + 10 + ) + + expect(fused[0].id).toBe('deep-shared') + }) + + it('does not let the first leg starve the second at small topK', () => { + const lexicalOnly = makeResult('lexical-only') + const vectorOnly = makeResult('vector-only') + + /** + * Rank 1 in each leg scores identically. Ordering by score alone would + * always emit the first list's row, so a `topK: 1` hybrid search would + * return exactly what vector-only search already returned. + */ + expect(fuseByReciprocalRank([[lexicalOnly], [vectorOnly]], 1).map((r) => r.id)).toEqual([ + 'lexical-only', + ]) + expect(fuseByReciprocalRank([[lexicalOnly], [vectorOnly]], 2).map((r) => r.id)).toEqual([ + 'lexical-only', + 'vector-only', + ]) + }) + + it('interleaves tied ranks so neither leg monopolizes the head', () => { + const legA = [makeResult('a1'), makeResult('a2'), makeResult('a3')] + const legB = [makeResult('b1'), makeResult('b2'), makeResult('b3')] + + expect(fuseByReciprocalRank([legA, legB], 6).map((r) => r.id)).toEqual([ + 'a1', + 'b1', + 'a2', + 'b2', + 'a3', + 'b3', + ]) + }) + + it('still floats a row found by both legs above every single-leg row', () => { + const shared = makeResult('shared') + const legA = [makeResult('a1'), shared] + const legB = [makeResult('b1'), shared] + + // shared is rank 2 in both legs (2/62) and outscores either rank-1 row (1/61). + expect(fuseByReciprocalRank([legA, legB], 3).map((r) => r.id)).toEqual(['shared', 'a1', 'b1']) + }) + + it('does not let a shared top hit evict the lexical-only row at topK 2', () => { + const shared = makeResult('shared') + const lexicalOnly = makeResult('lexical-only') + const vectorOnly = makeResult('vector-only') + + /** + * `shared` is rank 1 in both legs. Crediting it to only one leg would + * leave the round-robin owing the other leg the remaining slot, evicting + * the row that only the shared hit's leg could produce. + */ + const fused = fuseByReciprocalRank( + [ + [shared, lexicalOnly], + [shared, vectorOnly], + ], + 2 + ) + + expect(fused.map((r) => r.id)).toEqual(['shared', 'lexical-only']) + }) + + it('trims the fused list to topK', () => { + const rows = Array.from({ length: 8 }, (_, i) => makeResult(`chunk-${i}`)) + + expect(fuseByReciprocalRank([rows, []], 3)).toHaveLength(3) + }) + + it('returns an empty list when every leg is empty', () => { + expect(fuseByReciprocalRank([[], []], 10)).toEqual([]) + }) + }) + + describe('executeKeywordSearch', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('returns nothing for a whitespace-only query without touching the database', async () => { + const results = await executeKeywordSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + query: ' ', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results).toEqual([]) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('issues one query per knowledge base once the parallel threshold is crossed', async () => { + const knowledgeBaseIds = ['kb-1', 'kb-2', 'kb-3', 'kb-4', 'kb-5'] + expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(true) + + await executeKeywordSearch({ + knowledgeBaseIds, + topK: 10, + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + /** + * A single global LIMIT would let the lexically strongest base consume + * every slot, so an exact-token hit in a smaller base never reaches + * fusion. The vector leg already fans out here; both legs must match. + */ + expect(dbChainMockFns.select).toHaveBeenCalledTimes(knowledgeBaseIds.length) + }) + + it('ranks without selecting the embedding column, then hydrates the survivors', async () => { + queueTableRows(schemaMock.embedding, [{ id: 'kw-1', keywordRank: 0.9 }]) + queueTableRows(schemaMock.embedding, [makeResult('kw-1')]) + + const results = await executeKeywordSearch({ + knowledgeBaseIds: ['kb-1'], + topK: 10, + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id)).toEqual(['kw-1']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + + /** + * Projecting the distance in the ranking pass makes Postgres detoast the + * 1536-dimension vector for every full-text match before the LIMIT, so + * cost tracks how common the term is rather than topK. The ranking pass + * must select ids and relevance only. + */ + const rankingSelect = dbChainMockFns.select.mock.calls[0][0] + expect(Object.keys(rankingSelect)).toEqual(['id', 'keywordRank']) + expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toContain('distance') + }) + + it('uses a single query when the parallel threshold is not crossed', async () => { + const knowledgeBaseIds = ['kb-1', 'kb-2'] + expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(false) + + await executeKeywordSearch({ + knowledgeBaseIds, + topK: 10, + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + }) + + describe('executeKnowledgeSearch', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('throws when neither a query nor tag filters are provided', async () => { + await expect( + executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + }) + ).rejects.toThrow('A search query or tag filters are required') + }) + + it('throws when a query is provided without a query vector', async () => { + await expect( + executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + }) + ).rejects.toThrow('Query vector is required') + }) + + it('runs a single retrieval leg in vector mode', async () => { + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'vector', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id)).toEqual(['vector-hit']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + + it('runs both legs and fuses them in hybrid mode', async () => { + // Vector leg, then the keyword leg's ranking pass, then its hydration pass. + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }]) + queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')]) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) + }) + + it('falls back to vector results when the keyword leg fails', async () => { + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + + /** + * Both legs share one `orderBy` spy, so target the keyword leg by its + * ranking expression. Calling the untouched spy first captures the + * sentinel that tells the mock to build its normal chain, which the + * vector leg still needs. + */ + const chainDefault = dbChainMockFns.orderBy() + dbChainMockFns.orderBy.mockImplementation((fragment: unknown) => { + const text = (fragment as { strings?: string[] })?.strings?.join('') ?? '' + if (text.includes('ts_rank_cd')) { + throw new Error('tsquery blew up') + } + return chainDefault + }) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id)).toEqual(['vector-hit']) + }) + + it('skips both query legs when only tag filters are provided', async () => { + queueTableRows(schemaMock.embedding, [makeResult('tag-hit')]) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' } as never, + ], + }) + + expect(results.map((r) => r.id)).toEqual(['tag-hit']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + }) + describe('generateSearchEmbedding', () => { it('should use Azure OpenAI when KB-specific config is provided', async () => { const { env } = await import('@/lib/core/config/env') diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index afaff875b5b..1a3b62fbf87 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -1,8 +1,12 @@ import { db } from '@sim/db' import { document, embedding } from '@sim/db/schema' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' +const logger = createLogger('KnowledgeSearch') + export interface DocumentMetadata { filename: string sourceUrl: string | null @@ -306,6 +310,36 @@ function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: an return conditions } +/** + * Text-search configuration used to build the query. Must match the config the + * generated `embedding.content_tsv` column was built with + * (`to_tsvector('english', content)`) — a mismatch silently stops Postgres from + * using the `emb_content_fts_idx` GIN index and degrades to a sequential scan. + */ +const FTS_CONFIG = 'english' + +/** + * Reciprocal-rank-fusion damping constant. 60 is the value from the original RRF + * paper and matches the docs Ask-AI retriever (`apps/docs/app/api/chat/route.ts`). + */ +export const RRF_K = 60 + +/** + * Row visibility predicates shared by every search leg: a chunk is only + * retrievable when both it and its document are enabled, the document finished + * processing, and it has not been excluded, archived, or soft-deleted. + */ +function getVisibilityConditions() { + return [ + eq(embedding.enabled, true), + eq(document.enabled, true), + eq(document.processingStatus, 'completed'), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + ] +} + export function getQueryStrategy(kbCount: number, topK: number) { const useParallel = kbCount > 4 || (kbCount > 2 && topK > 50) const distanceThreshold = kbCount > 3 ? 0.8 : 1.0 @@ -512,6 +546,185 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise { + const { knowledgeBaseIds, topK, query, queryVector, structuredFilters } = params + + if (!query.trim()) { + return [] + } + + const tsQuery = sql`websearch_to_tsquery(${FTS_CONFIG}, ${query})` + const rankExpr = sql`ts_rank_cd(${embedding.contentTsv}, ${tsQuery})` + const tagFilterConditions = structuredFilters?.length + ? getStructuredTagFilters(structuredFilters, embedding) + : [] + + const rankConditions = (kbScope: SQL | undefined) => + and( + kbScope, + ...getVisibilityConditions(), + sql`${embedding.contentTsv} @@ ${tsQuery}`, + ...tagFilterConditions + ) + + /** Ranking pass: ids and relevance only, so no vector is read. */ + const rankRows = (kbScope: SQL | undefined, limit: number) => + db + .select({ id: embedding.id, keywordRank: rankExpr.as('keyword_rank') }) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where(rankConditions(kbScope)) + .orderBy(sql`${rankExpr} DESC`) + .limit(limit) + + const strategy = getQueryStrategy(knowledgeBaseIds.length, topK) + + let ranked: { id: string; keywordRank: number }[] + if (strategy.useParallel) { + const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5 + const perBase = await Promise.all( + knowledgeBaseIds.map((kbId) => rankRows(eq(embedding.knowledgeBaseId, kbId), parallelLimit)) + ) + ranked = perBase.flat().sort((a, b) => b.keywordRank - a.keywordRank) + } else { + ranked = await rankRows(inArray(embedding.knowledgeBaseId, knowledgeBaseIds), topK) + } + + const topIds = ranked.slice(0, topK).map((row) => row.id) + if (topIds.length === 0) { + return [] + } + + /** Hydration pass: full rows plus the cosine distance, bounded to the survivors. */ + const hydrated = await db + .select( + getSearchResultFields( + sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') + ) + ) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where(and(inArray(embedding.id, topIds), ...getVisibilityConditions())) + + const rowById = new Map(hydrated.map((row) => [row.id, row])) + return topIds.map((id) => rowById.get(id)).filter((row): row is SearchResult => row !== undefined) +} + +/** + * Fuse independently-ranked result lists by reciprocal rank: + * `score(row) = Σ 1 / (RRF_K + rank)` across the lists it appears in. + * + * Rank fusion is used rather than score normalization because cosine distance + * and `ts_rank_cd` are on incomparable scales with no corpus-independent + * mapping between them. Rows are deduped by chunk id, first occurrence wins. + * + * Equal scores are common and must not be broken by list order: rank *n* in one + * leg always ties rank *n* in every other leg, so sorting alone would let the + * first list monopolize the head of the output and starve the others entirely + * at small `topK`. Selection therefore drains each tie group round-robin, + * preferring the candidate whose least-served leg has been served least. + * + * A row is credited to *every* leg that returned it, not to one chosen leg: it + * satisfied all of them, and charging a shared hit to a single leg would leave + * the round-robin owing the other one a slot it has already been served — + * which at small `topK` evicts a row only the shared hit's leg could produce. + * A total tie goes to the earliest list, so callers put the leg whose hits the + * other leg cannot produce first. + */ +export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number): SearchResult[] { + const scores = new Map() + const rowById = new Map() + const legsOfRow = new Map() + + rankedLists.forEach((list, leg) => { + list.forEach((row, index) => { + scores.set(row.id, (scores.get(row.id) ?? 0) + 1 / (RRF_K + index + 1)) + if (!rowById.has(row.id)) { + rowById.set(row.id, row) + } + const legs = legsOfRow.get(row.id) + if (legs) { + if (!legs.includes(leg)) legs.push(leg) + } else { + legsOfRow.set(row.id, [leg]) + } + }) + }) + + // Stable sort keeps rowById insertion order (earliest leg first) inside each tie group. + const ordered = [...rowById.values()].sort( + (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0) + ) + + const contributed = rankedLists.map(() => 0) + /** How starved a candidate's most-neglected leg is; lower wins the tie. */ + const starvation = (id: string) => + Math.min(...(legsOfRow.get(id) ?? [0]).map((leg) => contributed[leg])) + + const fused: SearchResult[] = [] + let groupStart = 0 + + while (groupStart < ordered.length && fused.length < topK) { + const groupScore = scores.get(ordered[groupStart].id) ?? 0 + let groupEnd = groupStart + while (groupEnd < ordered.length && (scores.get(ordered[groupEnd].id) ?? 0) === groupScore) { + groupEnd++ + } + + const group = ordered.slice(groupStart, groupEnd) + while (group.length > 0 && fused.length < topK) { + let pick = 0 + for (let i = 1; i < group.length; i++) { + if (starvation(group[i].id) < starvation(group[pick].id)) { + pick = i + } + } + const [row] = group.splice(pick, 1) + fused.push(row) + for (const leg of legsOfRow.get(row.id) ?? []) { + contributed[leg]++ + } + } + + groupStart = groupEnd + } + + return fused +} + export async function handleTagAndVectorSearch(params: SearchParams): Promise { const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params @@ -537,3 +750,88 @@ export async function handleTagAndVectorSearch(params: SearchParams): Promise { + const { knowledgeBaseIds, topK, searchMode, query, queryVector, structuredFilters } = params + + const hasQuery = Boolean(query?.trim()) + const hasFilters = Boolean(structuredFilters && structuredFilters.length > 0) + + if (!hasQuery) { + if (!hasFilters) { + throw new Error('A search query or tag filters are required') + } + return await handleTagOnlySearch({ knowledgeBaseIds, topK, structuredFilters }) + } + + if (!queryVector) { + throw new Error('Query vector is required when searching with a query') + } + + const { distanceThreshold } = getQueryStrategy(knowledgeBaseIds.length, topK) + + const vectorSearch = hasFilters + ? handleTagAndVectorSearch({ + knowledgeBaseIds, + topK, + structuredFilters, + queryVector, + distanceThreshold, + }) + : handleVectorOnlySearch({ knowledgeBaseIds, topK, queryVector, distanceThreshold }) + + if (searchMode === 'vector') { + return await vectorSearch + } + + /** + * The lexical leg is best-effort: a failure there falls back to vector-only + * results rather than failing the whole search. + */ + const keywordSearch = executeKeywordSearch({ + knowledgeBaseIds, + topK, + query: query!, + queryVector, + structuredFilters, + }).catch((error) => { + logger.warn('Keyword search leg failed; falling back to vector-only results', { + error: getErrorMessage(error, 'Unknown error'), + }) + return [] as SearchResult[] + }) + + const [vectorResults, keywordResults] = await Promise.all([vectorSearch, keywordSearch]) + + /** + * Lexical leg first: on a total tie it wins, which is the behavior this mode + * exists for — an exact-token chunk the vector leg ranked below its distance + * threshold is precisely what a caller opted into hybrid to recover, and at + * `topK: 1` something has to win. + */ + return fuseByReciprocalRank([keywordResults, vectorResults], topK) +} diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 978fbfdf751..8f75f565454 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -12,10 +12,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockHandleVectorOnlySearch, - mockHandleTagOnlySearch, - mockHandleTagAndVectorSearch, - mockGetQueryStrategy, + mockExecuteKnowledgeSearch, mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, mockAuthenticateRequest, @@ -24,10 +21,7 @@ const { mockResolveSystemBillingAttribution, mockRecordSearchEmbeddingUsage, } = vi.hoisted(() => ({ - mockHandleVectorOnlySearch: vi.fn(), - mockHandleTagOnlySearch: vi.fn(), - mockHandleTagAndVectorSearch: vi.fn(), - mockGetQueryStrategy: vi.fn(), + mockExecuteKnowledgeSearch: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), mockGetDocumentMetadataByIds: vi.fn(), mockAuthenticateRequest: vi.fn(), @@ -51,10 +45,7 @@ const SYSTEM_BILLING_ATTRIBUTION = { } vi.mock('@/app/api/knowledge/search/utils', () => ({ - handleVectorOnlySearch: mockHandleVectorOnlySearch, - handleTagOnlySearch: mockHandleTagOnlySearch, - handleTagAndVectorSearch: mockHandleTagAndVectorSearch, - getQueryStrategy: mockGetQueryStrategy, + executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, })) @@ -115,12 +106,11 @@ describe('v1 knowledge search route — per-KB embedding model', () => { rateLimit: {}, }) mockValidateWorkspaceAccess.mockResolvedValue(null) - mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 }) mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false, }) - mockHandleVectorOnlySearch.mockResolvedValue([]) + mockExecuteKnowledgeSearch.mockResolvedValue([]) mockGetDocumentMetadataByIds.mockResolvedValue({}) mockResolveBillingAttribution.mockImplementation( ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => @@ -218,7 +208,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { hasAccess: true, knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'), }) - mockHandleVectorOnlySearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { documentId: 'doc-confluence', knowledgeBaseId: 'kb-confluence', @@ -250,7 +240,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { }) it('allows tag-only search across mixed embedding models', async () => { - mockHandleTagOnlySearch.mockResolvedValue([]) + mockExecuteKnowledgeSearch.mockResolvedValue([]) mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-mixed', 'text-embedding-3-small'), diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 3ac848b303d..490c8e88b7b 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -13,12 +13,9 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { + executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, - getQueryStrategy, - handleTagAndVectorSearch, - handleTagOnlySearch, - handleVectorOnlySearch, type SearchResult, } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' @@ -49,7 +46,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, topK, query, tagFilters } = parsed.data.body + const { workspaceId, topK, query, tagFilters, searchMode } = parsed.data.body const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) if (accessError) return accessError @@ -190,29 +187,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let queryEmbeddingIsBYOK: boolean | null = null if (!hasQuery && hasFilters) { - results = await handleTagOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, + searchMode, structuredFilters, }) - } else if (hasQuery && hasFilters) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId - ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) } else if (hasQuery) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) const queryEmbeddingResult = await generateSearchEmbedding( query!, queryEmbeddingModel, @@ -220,11 +201,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleVectorOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, + searchMode, + query, queryVector, - distanceThreshold: strategy.distanceThreshold, + structuredFilters: hasFilters ? structuredFilters : undefined, }) } else { return NextResponse.json( diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 659f479dc0d..7debc95c938 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -88,6 +88,18 @@ export const KnowledgeBlock: BlockConfig = { dependsOn: ['knowledgeBaseSelector'], condition: { field: 'operation', value: 'search' }, }, + { + id: 'searchMode', + title: 'Retrieval Mode', + type: 'dropdown', + options: [ + { label: 'Vector only', id: 'vector' }, + { label: 'Hybrid (full-text + vector)', id: 'hybrid' }, + ], + value: () => 'vector', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + }, { id: 'rerankerEnabled', title: 'Rerank Results', @@ -440,6 +452,10 @@ export const KnowledgeBlock: BlockConfig = { limit: { type: 'number', description: 'Max items to return' }, offset: { type: 'number', description: 'Pagination offset' }, tagFilters: { type: 'string', description: 'Tag filter criteria' }, + searchMode: { + type: 'string', + description: 'Retrieval mode: vector only (default) or hybrid (full-text + vector)', + }, rerankerEnabled: { type: 'boolean', description: 'Apply Cohere reranking to search results' }, rerankerModel: { type: 'string', description: 'Cohere rerank model identifier' }, rerankerInputCount: { diff --git a/apps/sim/lib/api/contracts/knowledge/search.ts b/apps/sim/lib/api/contracts/knowledge/search.ts index ea1dff75ce0..3550311f21d 100644 --- a/apps/sim/lib/api/contracts/knowledge/search.ts +++ b/apps/sim/lib/api/contracts/knowledge/search.ts @@ -10,6 +10,19 @@ export const knowledgeSearchTagFilterSchema = z.object({ valueTo: z.union([z.string(), z.number()]).optional(), }) +export const KNOWLEDGE_SEARCH_MODES = ['vector', 'hybrid'] as const + +/** + * Shared by the internal and v1 search contracts. Defaults to `vector` so every + * existing caller keeps its current ranking; hybrid is opt-in. + */ +export const knowledgeSearchModeSchema = z + .enum(KNOWLEDGE_SEARCH_MODES) + .optional() + .nullable() + .default('vector') + .transform((val) => val ?? 'vector') + export const knowledgeSearchBodySchema = z .object({ knowledgeBaseIds: z.union([ @@ -34,6 +47,12 @@ export const knowledgeSearchBodySchema = z .optional() .nullable() .transform((val) => val || undefined), + /** + * `vector` (default) is semantic-only retrieval. `hybrid` additionally runs a + * full-text leg and fuses the two by reciprocal rank, which recovers exact + * tokens (error codes, ticket keys, identifiers) that embeddings rank poorly. + */ + searchMode: knowledgeSearchModeSchema, rerankerEnabled: z.boolean().optional().default(false), rerankerModel: rerankerModelSchema.optional().default(DEFAULT_RERANKER_MODEL), /** diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 0134da1147a..76abfcf16a0 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { knowledgeSearchModeSchema } from '@/lib/api/contracts/knowledge/search' import { knowledgeBaseParamsSchema, knowledgeDocumentParamsSchema, @@ -133,6 +134,11 @@ export const v1KnowledgeSearchBodySchema = z query: z.string().optional(), topK: z.number().min(1).max(100).default(10), tagFilters: z.array(v1SearchTagFilterSchema).optional(), + /** + * `vector` (default) is semantic-only retrieval; `hybrid` fuses a full-text + * leg with it by reciprocal rank. + */ + searchMode: knowledgeSearchModeSchema, }) .refine( (data) => { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index af3374b925b..186e785d29c 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -74,8 +74,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/knowledge/search/utils', () => ({ - getQueryStrategy: vi.fn(), - handleVectorOnlySearch: vi.fn(), + executeKnowledgeSearch: vi.fn(), })) vi.mock('@/app/api/knowledge/utils', () => ({ checkDocumentWriteAccess: vi.fn(), diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index aececd01078..bf19100c9ec 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -50,7 +50,7 @@ import { } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' +import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, checkKnowledgeBaseAccess, @@ -221,7 +221,7 @@ export const knowledgeBaseServerTool: BaseServerTool = { }, }, }, + searchMode: { + type: 'string', + required: false, + visibility: 'user-only', + description: + "Retrieval mode: 'vector' (default) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both", + }, rerankerEnabled: { type: 'boolean', required: false, @@ -114,6 +121,7 @@ export const knowledgeSearchTool: ToolConfig = { query: params.query, topK: params.topK ? Math.max(1, Math.min(100, Number(params.topK))) : 10, ...(structuredFilters.length > 0 && { tagFilters: structuredFilters }), + ...(params.searchMode === 'hybrid' && { searchMode: 'hybrid' }), ...(rerankerEnabled && { rerankerEnabled: true, rerankerModel, diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 0ca0af53921..455c90f66c8 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -5,11 +5,16 @@ import { vi } from 'vitest' * Mimics drizzle-orm's sql tagged template. */ export function createMockSql() { - const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => ({ - strings, - values, - toSQL: () => ({ sql: strings.join('?'), params: values }), - }) + const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => { + const fragment = { + strings, + values, + toSQL: () => ({ sql: strings.join('?'), params: values }), + /** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */ + as: (alias: string) => ({ ...fragment, alias }), + } + return fragment + } sqlFn.raw = (rawSql: string) => ({ rawSql, From 5c29d833af6a092f3970f0807160d7e3292f9f48 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 31 Jul 2026 12:43:26 -0700 Subject: [PATCH 03/19] improvement(ui): drop full-content hover tooltips (#6128) * improvement(ui): drop full-content hover tooltips - remove the chunk-content tooltip in the KB chunks table (it forced the full chunk body on every truncated row) - remove the sub-block value tooltip on collapsed workflow blocks (whole prompts/code/JSON on hover) - replace the native `title` on subflow and note blocks with the clip-gated OverflowSpan - clip-gate the KB documents tags cell so it stops firing on fully visible tags - drop the dead tooltip on the resource header root title, which can never truncate * fix(workflow-renderer): default the optional note name for OverflowSpan --- .../resource-header/resource-header.tsx | 8 +-- .../knowledge/[id]/[documentId]/document.tsx | 14 +--- .../[workspaceId]/knowledge/[id]/base.tsx | 66 +++++++++++-------- .../src/note/note-block-view.tsx | 9 ++- .../src/subflow/subflow-node-view.tsx | 9 ++- .../src/workflow-block/sub-block-row-view.tsx | 7 +- 6 files changed, 55 insertions(+), 58 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index a28d735a1f8..df0960385c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -33,7 +33,6 @@ import { ArrowUpLeft } from 'lucide-react' import { createPortal } from 'react-dom' import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' export interface DropdownOption { label: string @@ -195,10 +194,9 @@ export const ResourceHeader = memo(function ResourceHeader({ {TitleIcon && } {titleLabel && ( - + + {titleLabel} + )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index f34ed657dbc..0f014c6a00d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -21,11 +21,7 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { - EMPTY_CELL_PLACEHOLDER, - FloatingOverflowText, - Resource, -} from '@/app/workspace/[workspaceId]/components' +import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' import { ChunkContextMenu, ChunkEditor, @@ -948,13 +944,9 @@ export function Document({ cells: { content: { content: ( - + - + ), }, index: { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index f5f988ec658..c89a42a609f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -18,9 +18,12 @@ import { chipContentLabelClass, chipVariants, cn, + FloatingTooltip, + isTextClipped, Loader, Tooltip, Trash, + useFloatingTooltip, } from '@sim/emcn' import { Database, DatabaseX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -177,6 +180,39 @@ interface TagValue { value: string } +/** + * Tags cell for the documents table. Shows the joined tag values inline and + * reveals the full `name: value` breakdown only when the inline text is + * actually clipped — an un-truncated cell already says everything the tooltip + * would. + */ +function DocumentTagsCell({ tags }: { tags: TagValue[] }) { + const { state, handlers } = useFloatingTooltip(isTextClipped) + + return ( + <> + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + {...handlers} + > + {tags.map((tag) => tag.value).join(', ')} + + +
+ {tags.map((tag) => ( +
+ {tag.displayName}: {tag.value} +
+ ))} +
+
+ + ) +} + /** * Computes tag values for a document */ @@ -1057,7 +1093,6 @@ export function KnowledgeBase({ const DocIcon = ConnectorIcon || getDocumentIcon(doc.mimeType, doc.filename) const tags = getDocumentTags(doc, tagDefinitions) - const tagsDisplayText = tags.map((t) => t.value).join(', ') const statusCell: ResourceCell = doc.processingStatus === 'failed' && doc.processingError @@ -1076,34 +1111,7 @@ export function KnowledgeBase({ : { content: getStatusBadge(doc) } const tagsCell: ResourceCell = - tags.length === 0 - ? { label: null } - : { - content: ( - - - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {tagsDisplayText} - - - -
- {tags.map((tag) => ( -
- {tag.displayName}:{' '} - {tag.value} -
- ))} -
-
-
- ), - } + tags.length === 0 ? { label: null } : { content: } return { id: doc.id, diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index 4b0a3e50159..c66369f9e97 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -4,6 +4,7 @@ import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { cn, handleKeyboardActivation } from '@sim/emcn' import { getEmbedInfo } from '@sim/utils/media-embed' +import { OverflowSpan } from '../lib/overflow-span' const EMBED_SCALE = 0.78 const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` @@ -217,15 +218,13 @@ export function NoteBlockView({
- - {name} - + />
diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 335a97b4591..f2eea5eef62 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -3,6 +3,7 @@ import { Badge, cn, handleKeyboardActivation } from '@sim/emcn' import { RepeatIcon, SplitIcon } from 'lucide-react' import { Handle, Position } from 'reactflow' import { HANDLE_POSITIONS } from '../dimensions' +import { OverflowSpan } from '../lib/overflow-span' import { tileIconColorClass } from '../lib/tile-icon-color' import type { BlockRunStatus, DiffStatus } from '../types' @@ -171,15 +172,13 @@ export function SubflowNodeView({ )} /> - - {blockName} - + />
{!isEnabled && disabled} diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx index 87c4ac824b4..9fac04f93f7 100644 --- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -33,13 +33,14 @@ export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRo className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize' /> {displayValue !== undefined && ( - + > + {displayValue} + )}
) From c0b19da782c3e3df324447049cec3d20206999eb Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:45:13 -0700 Subject: [PATCH 04/19] feat(pi): install Bun in cloud sandboxes (#6123) Co-authored-by: Bill Leoutsakos --- apps/docs/content/docs/en/workflows/blocks/pi.mdx | 2 +- apps/sim/scripts/build-pi-daytona-snapshot.ts | 6 ++++-- apps/sim/scripts/build-pi-e2b-template.ts | 6 ++++-- apps/sim/scripts/pi-sandbox-packages.ts | 11 +++++++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index dbe3a59c40a..b502d34e459 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -211,7 +211,7 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t ### Create PR [#setup-cloud-pr] -Create PR runs in a sandbox image with the Pi CLI and git baked in. +Create PR runs in a sandbox image with the Pi CLI, Git, Node.js, and Bun baked in. Repository dependencies are not preinstalled; Pi can run `bun install` when a repository needs them. 1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals Create PR, Update PR, and Review Code in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. These modes stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. diff --git a/apps/sim/scripts/build-pi-daytona-snapshot.ts b/apps/sim/scripts/build-pi-daytona-snapshot.ts index 99cce3bf645..e746ab92ac7 100644 --- a/apps/sim/scripts/build-pi-daytona-snapshot.ts +++ b/apps/sim/scripts/build-pi-daytona-snapshot.ts @@ -28,9 +28,10 @@ import { Daytona, Image } from '@daytona/sdk' import { getErrorMessage } from '@sim/utils/errors' import { PI_APT, + PI_BUN_VERSION_ASSERT, + PI_GLOBAL_NPM_PACKAGES, PI_NODE_MAJOR, PI_NODE_VERSION_ASSERT, - PI_NPM, PI_SANDBOX_CPU_COUNT, PI_SANDBOX_MEMORY_GB, } from '@/scripts/pi-sandbox-packages' @@ -63,7 +64,8 @@ export const piImage = Image.base(BASE_IMAGE).runCommands( // ships an older Node fails here rather than at the first agent run. `apt-get update && curl -fsSL https://deb.nodesource.com/setup_${PI_NODE_MAJOR}.x | bash - && ${APT_PREFIX} nodejs && rm -rf /var/lib/apt/lists/* && ${PI_NODE_VERSION_ASSERT}`, `apt-get update && ${APT_PREFIX} ${PI_APT.join(' ')} && rm -rf /var/lib/apt/lists/*`, - `npm install -g ${PI_NPM.join(' ')}`, + `npm install -g ${PI_GLOBAL_NPM_PACKAGES.join(' ')}`, + PI_BUN_VERSION_ASSERT, // The clone target. E2B's base ships a world-writable /code; Pi writes to // /workspace (cloud-review-tools.ts:14), so create it explicitly. 'mkdir -p /workspace' diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts index 53283566ba6..9ab2fa2e52b 100644 --- a/apps/sim/scripts/build-pi-e2b-template.ts +++ b/apps/sim/scripts/build-pi-e2b-template.ts @@ -19,9 +19,10 @@ import { defaultBuildLogger, Template, waitForTimeout } from '@e2b/code-interpreter' import { PI_APT, + PI_BUN_VERSION_ASSERT, + PI_GLOBAL_NPM_PACKAGES, PI_NODE_MAJOR, PI_NODE_VERSION_ASSERT, - PI_NPM, PI_SANDBOX_CPU_COUNT, PI_SANDBOX_MEMORY_MB, } from '@/scripts/pi-sandbox-packages' @@ -38,7 +39,8 @@ const piTemplate = Template() .fromTemplate('code-interpreter-v1') .runCmd(INSTALL_NODE_COMMAND, { user: 'root' }) .aptInstall([...PI_APT]) - .npmInstall([...PI_NPM], { g: true }) + .npmInstall([...PI_GLOBAL_NPM_PACKAGES], { g: true }) + .runCmd(PI_BUN_VERSION_ASSERT, { user: 'root' }) .setStartCmd(START_COMMAND, waitForTimeout(1_000)) async function main() { diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts index 90f3567219b..59d7c29632d 100644 --- a/apps/sim/scripts/pi-sandbox-packages.ts +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -13,8 +13,12 @@ * owns the Pi block. */ -/** Exact first-party Pi versions mirrored from bun.lock — image builds run npm independently. */ -export const PI_NPM = [ +/** Bun version mirrored from the root packageManager field. */ +export const PI_BUN_VERSION = '1.3.13' + +/** Exact global package versions mirrored from package.json and bun.lock. */ +export const PI_GLOBAL_NPM_PACKAGES = [ + `bun@${PI_BUN_VERSION}`, '@earendil-works/pi-coding-agent@0.80.10', '@earendil-works/pi-agent-core@0.80.10', '@earendil-works/pi-ai@0.80.10', @@ -51,6 +55,9 @@ export const PI_NODE_MAJOR = 22 export const PI_NODE_VERSION_ASSERT = 'node -e "const [major, minor] = process.versions.node.split(\'.\').map(Number); if (major < 22 || (major === 22 && minor < 19)) process.exit(1)"' +/** Fails the build loudly if the sandbox does not contain the repository's Bun version. */ +export const PI_BUN_VERSION_ASSERT = `test "$(bun --version)" = "${PI_BUN_VERSION}"` + /** * The review tools run `python3 /workspace/sim-review-tools.py` * (`cloud-review-tools.ts:15`). E2B's `code-interpreter-v1` base ships Python, so From e98715da01c566513649d04af3a4e31c13dc4284 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 31 Jul 2026 12:55:48 -0700 Subject: [PATCH 05/19] fix(realtime): noindex the socket server's 404 responses (#6129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(realtime): noindex the socket server's 404 responses The sockets.* hostnames are served by this server and return a plain JSON 404, which Google Search Console reports as crawl errors. Mark unmatched routes noindex so crawlers drop the hostnames instead of retrying them. * fix(realtime): noindex every response, not just the 404 Setting the header only on the 404 fallback covered the one response that crawlers already drop on status code alone, while /health — the sole route returning 200 with a body, and so the only indexable surface on the socket hostnames — stayed uncovered, with a test pinning it that way. Set it once on the handler instead. Node merges setHeader values into writeHead and no branch sets X-Robots-Tag, so it reaches every response. --- apps/realtime/src/routes/http.test.ts | 61 +++++++++++++++++++++++++++ apps/realtime/src/routes/http.ts | 2 + 2 files changed, 63 insertions(+) create mode 100644 apps/realtime/src/routes/http.test.ts diff --git a/apps/realtime/src/routes/http.test.ts b/apps/realtime/src/routes/http.test.ts new file mode 100644 index 00000000000..725341deac9 --- /dev/null +++ b/apps/realtime/src/routes/http.test.ts @@ -0,0 +1,61 @@ +import type { IncomingMessage, ServerResponse } from 'http' +import { describe, expect, it, vi } from 'vitest' +import type { IRoomManager } from '@/rooms' +import { createHttpHandler } from '@/routes/http' + +function createMocks(req: Partial) { + const setHeader = vi.fn() + const writeHead = vi.fn() + const end = vi.fn() + const logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() } + const roomManager = { + getTotalActiveConnections: vi.fn().mockResolvedValue(0), + isReady: vi.fn().mockReturnValue(true), + } as unknown as IRoomManager + + return { + handler: createHttpHandler(roomManager, logger), + req: { headers: {}, ...req } as IncomingMessage, + res: { setHeader, writeHead, end } as unknown as ServerResponse, + setHeader, + writeHead, + end, + } +} + +describe('createHttpHandler', () => { + /** + * `/health` is the only route on this server that returns 200 with a body, so + * it is the only genuinely indexable surface on the `sockets.*` hostnames. + * Node merges `setHeader` values into `writeHead`, and no branch here sets + * `X-Robots-Tag`, so the handler-level call reaches every response. + */ + it.each([ + ['health check', { method: 'GET', url: '/health' }], + ['unmatched route', { method: 'GET', url: '/' }], + ['unauthenticated internal API call', { method: 'POST', url: '/api/workflow-deleted' }], + ])('marks the %s noindex', async (_label, req) => { + const { handler, req: request, res, setHeader } = createMocks(req) + + await handler(request, res) + + expect(setHeader).toHaveBeenCalledWith('X-Robots-Tag', 'noindex, nofollow') + }) + + it('still serves the unmatched-route 404 unchanged', async () => { + const { handler, req, res, writeHead, end } = createMocks({ method: 'GET', url: '/' }) + + await handler(req, res) + + expect(writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }) + expect(end).toHaveBeenCalledWith(JSON.stringify({ error: 'Not found' })) + }) + + it('still serves the health check as 200', async () => { + const { handler, req, res, writeHead } = createMocks({ method: 'GET', url: '/health' }) + + await handler(req, res) + + expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }) + }) +}) diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 0f8ed73cc52..1e7b6faae99 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -59,6 +59,8 @@ function sendError(res: ServerResponse, message: string, status = 500): void { */ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return async (req: IncomingMessage, res: ServerResponse) => { + res.setHeader('X-Robots-Tag', 'noindex, nofollow') + // Health check doesn't require auth if (req.method === 'GET' && req.url === '/health') { try { From b4f027eeceee84ed930366506eba8bf216a02fe0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 13:08:02 -0700 Subject: [PATCH 06/19] fix(billing): point self-hosted upgrade CTAs at the hosted app (#6003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(billing): point self-hosted upgrade CTAs at the hosted app Self-hosted Chat bills against the sim.ai account behind COPILOT_API_KEY, but its 402 upgrade card linked to local billing settings that a self-hosted deployment does not have. Point those CTAs at the hosted app instead, and drop the local workspace-role gate that could hide the CTA from the only person able to act on it. Adds /upgrade, an account-scoped entry for callers that cannot know a workspace id. It delegates to /workspace?redirect=upgrade rather than re-deriving workspace resolution, inheriting local recency, stale-session recovery, and the no-workspace creation policy. * fix(billing): keep the upgrade intent through workspace creation A first-time visitor to /upgrade has no workspace to resolve, so /workspace creates one — and then hardcoded a redirect to home, silently dropping the upgrade intent. Route both exits through one destination helper so the created workspace lands on the plan picker with its reason intact. --- apps/sim/app/upgrade/page.tsx | 37 +++++++++++++++++++ .../components/special-tags/special-tags.tsx | 21 +++++++++-- .../workspace/[workspaceId]/upgrade/page.tsx | 24 +++++++++++- .../[workspaceId]/upgrade/upgrade.tsx | 16 ++------ apps/sim/app/workspace/page.tsx | 31 ++++++++++++---- apps/sim/lib/billing/upgrade-reasons.test.ts | 8 ++++ apps/sim/lib/billing/upgrade-reasons.ts | 24 ++++++++++-- 7 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 apps/sim/app/upgrade/page.tsx diff --git a/apps/sim/app/upgrade/page.tsx b/apps/sim/app/upgrade/page.tsx new file mode 100644 index 00000000000..aa9a9263ee0 --- /dev/null +++ b/apps/sim/app/upgrade/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { isUpgradeReason, UPGRADE_REASON_PARAM } from '@/lib/billing/upgrade-reasons' + +/** + * Public upgrade entry, for callers that cannot know a workspace id — a + * self-hosted deployment linking its users to the hosted plans, or an email + * that predates a workspace switch. + * + * Workspace resolution is not repeated here: `/workspace` already owns it, + * including local recency, last-active fallback, stale-session recovery, and + * the no-workspace creation policy. + */ +export default async function UpgradePage({ + searchParams, +}: { + searchParams: Promise> +}) { + const [session, params] = await Promise.all([getSession(), searchParams]) + + const rawReason = params[UPGRADE_REASON_PARAM] + const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason + const reason = isUpgradeReason(reasonValue) ? reasonValue : undefined + + const target = reason + ? `/workspace?redirect=upgrade&${UPGRADE_REASON_PARAM}=${reason}` + : '/workspace?redirect=upgrade' + + // `/workspace` recovers a signed-out visitor by hard-navigating to `/login` + // with no callback, which would drop the upgrade intent — carry it here + // instead, where the destination is still known. + if (!session?.user) { + redirect(`/login?callbackUrl=${encodeURIComponent(target)}`) + } + + redirect(target) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 2dd4e87562d..36353eafa38 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -10,6 +10,7 @@ import { ExpandableContent, SecretInput, SecretReveal, + SquareArrowUpRight, Tooltip, toast, } from '@sim/emcn' @@ -17,9 +18,11 @@ import { Cursor, TerminalWindow } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { ThinkingLoader } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' +import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { isHosted } from '@/lib/core/config/env-flags' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { getDesktopBridge } from '@/lib/desktop' import { @@ -1846,9 +1849,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { getSettingsHref } = useSettingsNavigation() - const settingsPath = getSettingsHref({ section: 'billing' }) const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' - const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id) + + // Self-hosted plan and limit both live on the hosted account, so local + // workspace billing roles say nothing about who may change them. + const href = isHosted + ? getSettingsHref({ section: 'billing' }) + : data.action === 'upgrade_plan' + ? buildHostedUpgradeUrl() + : HOSTED_BILLING_SETTINGS_URL + const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id) const unavailableMessage = hostContext.hostOrganizationId ? 'Contact an organization admin to manage this workspace’s usage limits.' : 'Only the workspace owner can manage this workspace’s usage limits.' @@ -1880,11 +1890,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {

{canManageBilling ? ( {buttonLabel} - + {isHosted ? : } ) : (

diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx index af1e0f80f1e..d86ed09d2a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx @@ -1,15 +1,37 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { + buildHostedUpgradeUrl, + isUpgradeReason, + UPGRADE_REASON_PARAM, +} from '@/lib/billing/upgrade-reasons' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade' export const metadata: Metadata = { title: 'Upgrade' } export default async function UpgradePage({ params, + searchParams, }: { params: Promise<{ workspaceId: string }> + searchParams: Promise> }) { - const { workspaceId } = await params + const [{ workspaceId }, query] = await Promise.all([params, searchParams]) + + // Both are build constants, so resolve them here rather than mounting a page + // whose only job would be to navigate away. + if (!isHosted) { + const rawReason = query[UPGRADE_REASON_PARAM] + const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason + redirect(buildHostedUpgradeUrl(isUpgradeReason(reasonValue) ? reasonValue : undefined)) + } + + if (!isBillingEnabled) { + redirect(`/workspace/${workspaceId}/home`) + } + return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 20e698d4a2c..547008947c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -16,7 +16,6 @@ import { import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants' import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { BillingPeriodToggle, @@ -73,23 +72,16 @@ export function Upgrade({ workspaceId }: UpgradeProps) { router.replace(origin ?? `/workspace/${workspaceId}/home`) }, [origin, router, workspaceId]) - // Enterprise manages billing out-of-band, and self-hosted deployments with - // billing disabled have no plans to surface — redirect to home in both cases. + // Enterprise manages billing out-of-band, so there is no plan to pick here. + // The self-hosted and billing-disabled cases are build constants, not reactive + // state — page.tsx resolves those before this ever mounts. useEffect(() => { - if (!isBillingEnabled) { - router.replace(`/workspace/${workspaceId}/home`) - return - } if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) { router.replace(`/workspace/${workspaceId}/home`) } }, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId]) - if ( - !isBillingEnabled || - state.isLoading || - (canManageBilling && state.subscription.isEnterprise) - ) { + if (state.isLoading || (canManageBilling && state.subscription.isEnterprise)) { return null } diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index cb6e4d95953..5db8905baae 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -11,6 +11,11 @@ import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' +import { + buildUpgradeHref, + isUpgradeReason, + UPGRADE_REASON_PARAM, +} from '@/lib/billing/upgrade-reasons' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' @@ -115,6 +120,20 @@ export default function WorkspacePage() { if (isWorkspacesLoading || workspacesError || !data) return + const urlParams = new URLSearchParams(window.location.search) + const redirectWorkflowId = urlParams.get('redirect_workflow') + const redirectTarget = urlParams.get('redirect') + const rawReason = urlParams.get(UPGRADE_REASON_PARAM) + + // `?redirect=upgrade` is how a caller that cannot know a workspace id — a + // self-hosted deployment, an email — reaches the plan picker. It has to + // survive workspace creation too: a first-time visitor has no workspace to + // resolve, and dropping the intent lands them on home with no explanation. + const destinationFor = (id: string) => + redirectTarget === 'upgrade' + ? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined) + : `/workspace/${id}/home` + const { workspaces, lastActiveWorkspaceId, creationPolicy } = data if (workspaces.length === 0) { @@ -135,15 +154,12 @@ export default function WorkspacePage() { return } hasRedirectedRef.current = true - handleNoWorkspaces(router, () => setRecoveryFailed(true)) + handleNoWorkspaces(router, () => setRecoveryFailed(true), destinationFor) return } hasRedirectedRef.current = true - const urlParams = new URLSearchParams(window.location.search) - const redirectWorkflowId = urlParams.get('redirect_workflow') - const localRecentId = WorkspaceRecencyStorage.getMostRecent() const findWorkspace = (id: string | null) => id ? workspaces.find((w) => w.id === id) : undefined @@ -157,7 +173,7 @@ export default function WorkspacePage() { } logger.info(`Redirecting to workspace: ${targetWorkspace.id}`) - router.replace(`/workspace/${targetWorkspace.id}/home`) + router.replace(destinationFor(targetWorkspace.id)) }, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router]) const blockedPolicy = @@ -242,7 +258,8 @@ async function handleWorkflowRedirect( async function handleNoWorkspaces( router: ReturnType, - onUnrecoverable: () => void + onUnrecoverable: () => void, + destinationFor: (workspaceId: string) => string ): Promise { logger.warn('No workspaces found, creating default workspace') try { @@ -252,7 +269,7 @@ async function handleNoWorkspaces( if (data.workspace?.id) { logger.info(`Created default workspace: ${data.workspace.id}`) sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY) - router.replace(`/workspace/${data.workspace.id}/home`) + router.replace(destinationFor(data.workspace.id)) return } logger.error('Failed to create default workspace') diff --git a/apps/sim/lib/billing/upgrade-reasons.test.ts b/apps/sim/lib/billing/upgrade-reasons.test.ts index 5505a576de6..2d5a5b785f4 100644 --- a/apps/sim/lib/billing/upgrade-reasons.test.ts +++ b/apps/sim/lib/billing/upgrade-reasons.test.ts @@ -3,7 +3,9 @@ */ import { describe, expect, it } from 'vitest' import { + buildHostedUpgradeUrl, buildUpgradeHref, + HOSTED_BILLING_SETTINGS_URL, isUpgradeReason, UPGRADE_REASON_COPY, UPGRADE_REASONS, @@ -31,6 +33,12 @@ describe('upgrade-reasons', () => { expect(buildUpgradeHref('ws-1', 'tables')).toBe('/workspace/ws-1/upgrade?reason=tables') }) + it('builds absolute hosted URLs for self-hosted deployments', () => { + expect(buildHostedUpgradeUrl()).toBe('https://www.sim.ai/upgrade') + expect(buildHostedUpgradeUrl('credits')).toBe('https://www.sim.ai/upgrade?reason=credits') + expect(HOSTED_BILLING_SETTINGS_URL).toBe('https://www.sim.ai/account/settings/billing') + }) + it('guards known reasons', () => { expect(isUpgradeReason('storage')).toBe(true) expect(isUpgradeReason('seats')).toBe(true) diff --git a/apps/sim/lib/billing/upgrade-reasons.ts b/apps/sim/lib/billing/upgrade-reasons.ts index 9e8986f1938..7d25d93fe25 100644 --- a/apps/sim/lib/billing/upgrade-reasons.ts +++ b/apps/sim/lib/billing/upgrade-reasons.ts @@ -2,10 +2,11 @@ * Upgrade-reason registry. * * Single source of truth for the language shown when a user is routed to the - * upgrade page after hitting a usage limit. The same copy drives both the - * upgrade-page header and the threshold/limit emails, so the in-app and email - * journeys never drift apart. + * upgrade page after hitting a usage limit, and for where that route points. + * The same copy drives both the upgrade-page header and the threshold/limit + * emails, so the in-app and email journeys never drift apart. */ +import { SITE_URL } from '@/lib/core/utils/urls' /** The limit categories that can route a user to the upgrade page. */ export const UPGRADE_REASONS = ['credits', 'storage', 'tables', 'seats'] as const @@ -86,3 +87,20 @@ export function buildUpgradeHref(workspaceId: string, reason?: UpgradeReason): s const base = `/workspace/${workspaceId}/upgrade` return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base } + +/** + * Absolute upgrade URL on the hosted app. + * + * Self-hosted deployments talk to Chat through a Chat key issued by the user's + * sim.ai account, so their plan and credits live there rather than on the local + * instance. A local workspace id is meaningless on the hosted app, so this + * points at the account-scoped `/upgrade` entry, which resolves the signed-in + * user's own workspace. + */ +export function buildHostedUpgradeUrl(reason?: UpgradeReason): string { + const base = `${SITE_URL}/upgrade` + return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base +} + +/** Account billing settings on the hosted app, for raising a usage limit. */ +export const HOSTED_BILLING_SETTINGS_URL = `${SITE_URL}/account/settings/billing` as const From 422b4919f1d51dea2cd42cf74b4e34f10206d90a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 31 Jul 2026 13:20:30 -0700 Subject: [PATCH 07/19] chore(trigger): upgrade version (#6127) --- .github/workflows/ci.yml | 2 +- apps/sim/package.json | 4 +- apps/sim/trigger.config.ts | 2 +- bun.lock | 76 ++++++++++---------------------------- 4 files changed, 24 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f038327feb..e91c1026469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,7 +252,7 @@ jobs: echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 exit 1 fi - bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim + bunx trigger.dev@4.5.7 deploy --env preview --branch dev-sim # Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR. # Runs in parallel with tests — only immutable sha tags are pushed here, and diff --git a/apps/sim/package.json b/apps/sim/package.json index 2fc8a509e82..399cf35e041 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -133,7 +133,7 @@ "@tiptap/react": "3.26.1", "@tiptap/starter-kit": "3.26.1", "@tiptap/suggestion": "3.26.1", - "@trigger.dev/sdk": "4.4.3", + "@trigger.dev/sdk": "4.5.7", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -238,7 +238,7 @@ "@sim/tsconfig": "workspace:*", "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", - "@trigger.dev/build": "4.4.3", + "@trigger.dev/build": "4.5.7", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/fluent-ffmpeg": "2.1.28", diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 470884e3dce..2cc7c6e28dc 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -48,7 +48,7 @@ const grafanaTelemetry = grafanaFullyConfigured export default defineConfig({ project: env.TRIGGER_PROJECT_ID!, - runtime: 'node-22', + runtime: 'node-24', logLevel: 'log', maxDuration: 5400, retries: { diff --git a/bun.lock b/bun.lock index 1842309a2e6..a5d660dbe3b 100644 --- a/bun.lock +++ b/bun.lock @@ -237,7 +237,7 @@ "@tiptap/react": "3.26.1", "@tiptap/starter-kit": "3.26.1", "@tiptap/suggestion": "3.26.1", - "@trigger.dev/sdk": "4.4.3", + "@trigger.dev/sdk": "4.5.7", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -342,7 +342,7 @@ "@sim/tsconfig": "workspace:*", "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", - "@trigger.dev/build": "4.4.3", + "@trigger.dev/build": "4.5.7", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/fluent-ffmpeg": "2.1.28", @@ -1439,7 +1439,7 @@ "@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg=="], - "@opentelemetry/host-metrics": ["@opentelemetry/host-metrics@0.37.0", "", { "dependencies": { "systeminformation": "5.23.8" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gf6nRFci0PTni9R1QQKjZ2uZE4Y6olLKhlwdM0qqLbbn3SBVKyP2jyBMiosBTHtRNLjY7s8hzQ44eLdK5wkGNQ=="], + "@opentelemetry/host-metrics": ["@opentelemetry/host-metrics@0.38.3", "", { "dependencies": { "systeminformation": "^5.31.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8iSOA8VPGoB5p/RIC8n/dcSe4cluCEWoznWENZfXR8sWQOQvergFu7v798xp7S5WQlZo1zfn1nVXx8dbyQ9m6Q=="], "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.217.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.217.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w=="], @@ -1707,7 +1707,7 @@ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], - "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], + "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.10", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], @@ -1987,11 +1987,11 @@ "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], - "@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="], + "@trigger.dev/build": ["@trigger.dev/build@4.5.7", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.5.7", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-rlUhq0ivhCHRahB60g1agmj3ZNaVfcATvv71WnNd+28D3fcCsDaIx8+CajdghiQpccZUjhzvR/rnLJQyK3GjQQ=="], - "@trigger.dev/core": ["@trigger.dev/core@4.4.3", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", "@opentelemetry/exporter-metrics-otlp-http": "0.203.0", "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@opentelemetry/host-metrics": "^0.37.0", "@opentelemetry/instrumentation": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@s2-dev/streamstore": "0.22.5", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-4srm2UGoDEcHO29Lqp4Isioq+b6au0EjW9/pjYmzOSxXqGPFDjPquK0BnKYGHyAbKYxuBx8wr2T/ru+zbY0/Jg=="], + "@trigger.dev/core": ["@trigger.dev/core@4.5.7", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.1", "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-logs-otlp-http": "0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/exporter-trace-otlp-http": "0.218.0", "@opentelemetry/host-metrics": "^0.38.3", "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "1.41.1", "@s2-dev/streamstore": "0.22.10", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-Q5vI1zLtIxZMLq3LzdbrWNHtLkhyIvUtQ1toX0Bayn71RdMraTl3zhB95I/6LHuz7ZS7HSyO/zDy6Cs0gIm5rw=="], - "@trigger.dev/sdk": ["@trigger.dev/sdk@4.4.3", "", { "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "4.4.3", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, "peerDependencies": { "ai": "^4.2.0 || ^5.0.0 || ^6.0.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai"] }, "sha512-ghJkak+PTBJJ9HiHMcnahJmzjsgCzYiIHu5Qj5R7I9q5LS6i7mkx169rB/tOE9HLadd4HSu3yYA5DrH4wXhZuw=="], + "@trigger.dev/sdk": ["@trigger.dev/sdk@4.5.7", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", "@trigger.dev/core": "4.5.7", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "ws": "^8.11.0" }, "peerDependencies": { "@ai-sdk/otel": ">=1.0.0-0 <2", "ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8", "react": "^18.0 || ^19.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/otel", "ai", "react"] }, "sha512-aYUQ/U6wyf96Bkm/beJte4HPO5E5bWTWIxQkF4Hjrj7r2M0iOuCdYZ0OOhVwUn9OmqCkBshGbzWTXF4IzNhQ5Q=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A=="], @@ -4267,7 +4267,7 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "systeminformation": ["systeminformation@5.23.8", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ=="], + "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -4901,31 +4901,17 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@trigger.dev/core/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], - "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/sdk-logs": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw=="], - "@trigger.dev/core/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-metrics": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/sdk-logs": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-metrics": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HFSW10y8lY6BTZecGNpV3GpoSy7eaO0Z6GATwZasnT4bEsILp8UJXNG5OmEsz4SdwCSYvyCbTJdNbZP3/8LGCQ=="], + "@trigger.dev/core/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZDiaswNYo0yq/cy1bBLJFe691izEJ6IgNmkjm4C6kE9ub/OMQqDXORx2D2j8fzTBTxONyzusbaZlqtfmyqURPw=="], - - "@trigger.dev/core/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ=="], - - "@trigger.dev/core/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="], - - "@trigger.dev/core/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw=="], - - "@trigger.dev/core/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.1", "@opentelemetry/core": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA=="], - - "@trigger.dev/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], + "@trigger.dev/core/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], "@trigger.dev/core/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], @@ -4939,14 +4925,8 @@ "@trigger.dev/core/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "@trigger.dev/sdk/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - - "@trigger.dev/sdk/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], - "@trigger.dev/sdk/cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="], - "@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@types/archiver/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -5543,31 +5523,17 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@trigger.dev/core/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - - "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], - - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], - - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], - "@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], - "@trigger.dev/core/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], - - "@trigger.dev/core/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], "@trigger.dev/core/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -5855,8 +5821,6 @@ "@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], - "@trigger.dev/core/socket.io-client/engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], "@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="], From 648dd345fa67221510504d8c00589ee0c6141dfe Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 13:34:12 -0700 Subject: [PATCH 08/19] feat(notifications): email on schedule auto-disable and 100% usage limit (#6038) * feat(notifications): email on schedule auto-disable and 100% usage limit * fix(notifications): keep partial recipients when one lookup fails --- apps/sim/app/api/emails/preview/route.ts | 42 ++++ .../app/api/schedules/execute/route.test.ts | 71 ++++-- apps/sim/app/api/schedules/execute/route.ts | 81 +++++-- .../sim/background/schedule-execution.test.ts | 101 ++++++++ apps/sim/background/schedule-execution.ts | 132 ++++++++-- apps/sim/components/emails/billing/index.ts | 1 + .../billing/usage-limit-reached-email.tsx | 81 +++++++ apps/sim/components/emails/index.ts | 2 + .../components/emails/notifications/index.ts | 1 + .../notifications/schedule-disabled-email.tsx | 83 +++++++ .../emails/render-notifications.test.ts | 98 ++++++++ apps/sim/components/emails/render.ts | 25 ++ apps/sim/components/emails/subjects.ts | 3 + apps/sim/lib/billing/core/usage.test.ts | 209 +++++++++++++++- apps/sim/lib/billing/core/usage.ts | 134 ++++++----- .../schedules/disable-notifications.test.ts | 227 ++++++++++++++++++ .../schedules/disable-notifications.ts | 175 ++++++++++++++ .../workflows/schedules/disable-reasons.ts | 23 ++ 18 files changed, 1356 insertions(+), 133 deletions(-) create mode 100644 apps/sim/background/schedule-execution.test.ts create mode 100644 apps/sim/components/emails/billing/usage-limit-reached-email.tsx create mode 100644 apps/sim/components/emails/notifications/index.ts create mode 100644 apps/sim/components/emails/notifications/schedule-disabled-email.tsx create mode 100644 apps/sim/components/emails/render-notifications.test.ts create mode 100644 apps/sim/lib/workflows/schedules/disable-notifications.test.ts create mode 100644 apps/sim/lib/workflows/schedules/disable-notifications.ts create mode 100644 apps/sim/lib/workflows/schedules/disable-reasons.ts diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index c99e3ff9f6e..c4791882bb4 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -11,6 +11,8 @@ import { renderPasswordResetEmail, renderPaymentFailedEmail, renderPlanWelcomeEmail, + renderScheduleDisabledEmail, + renderUsageLimitReachedEmail, renderUsageThresholdEmail, renderWelcomeEmail, renderWorkspaceInvitationEmail, @@ -93,6 +95,43 @@ const emailTemplates = { billingPortalUrl: 'https://sim.ai/settings/billing', failureReason: 'Card declined', }), + 'usage-limit-reached': () => + renderUsageLimitReachedEmail({ + userName: 'John', + planName: 'Pro', + scope: 'user', + currentUsage: 20, + limit: 20, + ctaLink: 'https://sim.ai/settings/billing', + }), + 'usage-limit-reached-org': () => + renderUsageLimitReachedEmail({ + userName: 'John', + planName: 'Team', + scope: 'organization', + currentUsage: 500, + limit: 500, + ctaLink: 'https://sim.ai/organization/org_123/settings/billing', + }), + + // Operational notification emails + 'schedule-disabled': () => + renderScheduleDisabledEmail({ + recipientName: 'John', + kind: 'workflow', + resourceName: 'Daily digest', + reason: 'consecutive_failures', + failedCount: 100, + manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456', + }), + 'schedule-disabled-auth': () => + renderScheduleDisabledEmail({ + recipientName: 'John', + kind: 'job', + resourceName: 'Weekly report', + reason: 'authentication_error', + manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks', + }), } as const type EmailTemplate = keyof typeof emailTemplates @@ -122,7 +161,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'plan-welcome-team', 'credit-purchase', 'payment-failed', + 'usage-limit-reached', + 'usage-limit-reached-org', ], + Notifications: ['schedule-disabled', 'schedule-disabled-auth'], } const categoryHtml = Object.entries(categories) diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 4de1d5aaebf..d738fb326a7 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -30,6 +30,8 @@ const { mockShouldExecuteInline, mockResolveSystemBillingAttribution, mockAssertBillingAttributionSnapshot, + mockApplyScheduleFailureUpdate, + mockNotifyScheduleAutoDisabled, } = vi.hoisted(() => ({ mockVerifyCronAuth: vi.fn().mockReturnValue(null), mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), @@ -44,6 +46,8 @@ const { mockShouldExecuteInline: vi.fn().mockReturnValue(false), mockResolveSystemBillingAttribution: vi.fn(), mockAssertBillingAttributionSnapshot: vi.fn(), + mockApplyScheduleFailureUpdate: vi.fn().mockResolvedValue({ updated: true, disabled: false }), + mockNotifyScheduleAutoDisabled: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/auth/internal', () => ({ @@ -59,15 +63,11 @@ vi.mock('@/background/schedule-execution', () => ({ executeScheduleJob: mockExecuteScheduleJob, executeJobInline: mockExecuteJobInline, releaseScheduleLock: mockReleaseScheduleLock, - buildScheduleFailureUpdate: (now: Date, nextRunAt: Date | null) => ({ - updatedAt: now, - lastQueuedAt: null, - nextRunAt, - failedCount: { type: 'sql' }, - lastFailedAt: now, - status: { type: 'sql' }, - infraRetryCount: 0, - }), + applyScheduleFailureUpdate: mockApplyScheduleFailureUpdate, +})) + +vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({ + notifyScheduleAutoDisabled: mockNotifyScheduleAutoDisabled, })) vi.mock('@/lib/core/async-jobs', () => ({ @@ -730,11 +730,11 @@ describe('Scheduled Workflow Execution API Route', () => { error: expect.stringContaining('exhausted retry attempts'), }) ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith( expect.objectContaining({ - lastQueuedAt: null, - lastFailedAt: expect.any(Date), - nextRunAt: expect.any(Date), + scheduleId: 'schedule-1', + expectedLastQueuedAt: claimedAt, + executor: expect.anything(), }) ) }) @@ -779,12 +779,11 @@ describe('Scheduled Workflow Execution API Route', () => { await runScheduleTick('test-request-id') expect(mockEnqueue).not.toHaveBeenCalled() - expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith( expect.objectContaining({ - lastQueuedAt: null, - lastFailedAt: expect.any(Date), + scheduleId: schedule.id, + expectedLastQueuedAt: claimedAt, nextRunAt: expect.any(Date), - infraRetryCount: 0, }) ) expect(dbChainMockFns.set).not.toHaveBeenCalledWith( @@ -794,6 +793,44 @@ describe('Scheduled Workflow Execution API Route', () => { ) }) + it('emails the schedule owners when a non-retryable setup failure disables the schedule', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant')) + mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: true }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + + expect(mockNotifyScheduleAutoDisabled).toHaveBeenCalledWith( + expect.objectContaining({ scheduleId: schedule.id, reason: 'consecutive_failures' }) + ) + }) + + it('does not email when the failure update leaves the schedule active', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant')) + mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: false }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + + expect(mockNotifyScheduleAutoDisabled).not.toHaveBeenCalled() + }) + it('uses one backend mode decision for slot accounting and schedule processing', async () => { mockShouldExecuteInline.mockReturnValue(true) dbChainMockFns.limit diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 13f87274810..debf5680af0 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -22,6 +22,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' import { SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, SCHEDULE_EXECUTION_QUEUE_NAME, @@ -31,7 +32,7 @@ import { } from '@/lib/workflows/schedules/execution-limits' import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry' import { - buildScheduleFailureUpdate, + applyScheduleFailureUpdate, executeJobInline, executeScheduleJob, releaseScheduleLock, @@ -43,6 +44,12 @@ export const maxDuration = 3600 const logger = createLogger('ScheduledExecuteAPI') const WORKFLOW_CHUNK_SIZE = 100 +/** + * Recovery sweeps a batch of up to `STALE_SCHEDULE_RECOVERY_BATCH_SIZE` schedules, + * each fanning out to every workspace admin. Cap the mail so one tick can't turn + * into hundreds of inline sends; the remainder is logged. + */ +const STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT = 25 const JOB_CHUNK_SIZE = 100 const MAX_TICK_DURATION_MS = 3 * 60 * 1000 const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() @@ -394,20 +401,22 @@ async function markClaimedScheduleFailed( context: string ): Promise { const now = new Date() - await db - .update(workflowSchedule) - .set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(schedule, now))) - .where( - and( - eq(workflowSchedule.id, schedule.id), - isNull(workflowSchedule.archivedAt), - eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt) - ) - ) - .catch((error) => { - logger.error(`[${requestId}] ${context}`, error) - throw error + const { disabled } = await applyScheduleFailureUpdate({ + scheduleId: schedule.id, + now, + nextRunAt: getScheduleNextRunAt(schedule, now), + expectedLastQueuedAt, + requestId, + context, + }) + + if (disabled) { + await notifyScheduleAutoDisabled({ + scheduleId: schedule.id, + reason: 'consecutive_failures', + requestId, }) + } } async function deferClaimedScheduleAfterQueueFailure( @@ -491,6 +500,13 @@ async function handleClaimedScheduleSetupFailure( async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { const staleStartedBefore = getStaleScheduleExecutionCutoff(now) + /** + * Collected inside the transaction, flushed after it commits. Emailing inside + * would both notify about writes a rollback discards and issue pooled-client + * reads while the transaction still holds row locks under the advisory lock. + */ + const disabledScheduleIds: string[] = [] + await db.transaction(async (tx) => { const [lock] = await tx.execute<{ acquired: boolean }>( sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${SCHEDULE_EXECUTION_QUEUE_NAME}, 0)) AS acquired` @@ -539,16 +555,17 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { const claimedAt = getSchedulePayloadClaimedAt(payload) if (!payload || !claimedAt) continue - await tx - .update(workflowSchedule) - .set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(payload, now))) - .where( - and( - eq(workflowSchedule.id, payload.scheduleId), - isNull(workflowSchedule.archivedAt), - eq(workflowSchedule.lastQueuedAt, claimedAt) - ) - ) + const { disabled } = await applyScheduleFailureUpdate({ + scheduleId: payload.scheduleId, + now, + nextRunAt: getScheduleNextRunAt(payload, now), + expectedLastQueuedAt: claimedAt, + requestId: 'stale-schedule-recovery', + context: `Error updating schedule ${payload.scheduleId} after stale lease recovery`, + executor: tx, + }) + + if (disabled) disabledScheduleIds.push(payload.scheduleId) } if (retryableRows.length > 0) { @@ -568,6 +585,22 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { ) } }) + + const notifiable = disabledScheduleIds.slice(0, STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT) + if (disabledScheduleIds.length > notifiable.length) { + logger.warn('Capped schedule auto-disable notifications for stale recovery batch', { + disabled: disabledScheduleIds.length, + notified: notifiable.length, + }) + } + + for (const scheduleId of notifiable) { + await notifyScheduleAutoDisabled({ + scheduleId, + reason: 'consecutive_failures', + requestId: 'stale-schedule-recovery', + }) + } } function isStaleDatabaseScheduleJob(job: { status: string; startedAt?: Date }): boolean { diff --git a/apps/sim/background/schedule-execution.test.ts b/apps/sim/background/schedule-execution.test.ts new file mode 100644 index 00000000000..8ebb5cdd8c4 --- /dev/null +++ b/apps/sim/background/schedule-execution.test.ts @@ -0,0 +1,101 @@ +/** + * Covers the `active -> disabled` edge detection that drives the auto-disable + * email. `applyScheduleFailureUpdate` is the exported seam over the shared + * guarded UPDATE, so asserting on it also pins the behavior of the trigger.dev + * call sites that go through the same helper. + * + * @vitest-environment node + */ +import { databaseMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { notifyMock } = vi.hoisted(() => ({ + notifyMock: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({ + notifyScheduleAutoDisabled: notifyMock, +})) + +// This module imports its tables from `@sim/db` directly, which the global mock +// does not re-export. Widen it rather than rewriting the source's imports. +vi.mock('@sim/db', () => ({ ...databaseMock, ...schemaMock })) + +import { applyScheduleFailureUpdate, releaseScheduleLock } from '@/background/schedule-execution' + +const BASE = { + scheduleId: 'schedule-1', + now: new Date('2025-01-01T00:00:00.000Z'), + nextRunAt: new Date('2025-01-01T01:00:00.000Z'), + expectedLastQueuedAt: new Date('2024-12-31T23:00:00.000Z'), + requestId: 'req-1', + context: 'test context', +} + +afterAll(() => { + resetDbChainMock() +}) + +describe('applyScheduleFailureUpdate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('reports disabled when the write returns a disabled row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + const result = await applyScheduleFailureUpdate(BASE) + + expect(result).toEqual({ updated: true, disabled: true }) + }) + + it('reports not disabled below the threshold (the 99-of-100 case)', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'active' }]) + + const result = await applyScheduleFailureUpdate(BASE) + + expect(result).toEqual({ updated: true, disabled: false }) + }) + + it('reports neither updated nor disabled when the claim guard matches no row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await applyScheduleFailureUpdate(BASE) + + expect(result).toEqual({ updated: false, disabled: false }) + }) + + it('defers the email so an in-transaction caller can send it after commit', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + await applyScheduleFailureUpdate(BASE) + + expect(notifyMock).not.toHaveBeenCalled() + }) + + it('excludes already-disabled rows so a disable is reported at most once', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + await applyScheduleFailureUpdate(BASE) + + const condition = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect(JSON.stringify(condition)).toContain('disabled') + }) +}) + +describe('releaseScheduleLock', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('never notifies, even when the row it releases is already disabled', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + const released = await releaseScheduleLock('schedule-1', 'req-1', new Date(), 'release') + + expect(released).toBe(true) + expect(notifyMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index ed8dce6e25b..b684f180dc7 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -30,6 +30,7 @@ import { getExecutionTimeout, getTimeoutErrorMessage, } from '@/lib/core/execution-limits' +import type { DbOrTx } from '@/lib/db/types' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -40,6 +41,8 @@ import { } from '@/lib/workflows/executor/execution-core' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' +import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' import { SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, SCHEDULE_EXECUTION_QUEUE_NAME, @@ -70,6 +73,12 @@ type WorkflowScheduleUpdate = Partial> +/** Result of a guarded schedule UPDATE. `status` is the row's value after the write. */ +type ScheduleUpdateOutcome = { + updated: boolean + status: string | null +} + function incrementScheduleFailedCount(): SQL { return sql`COALESCE(${workflowSchedule.failedCount}, 0) + 1` } @@ -141,8 +150,23 @@ async function applyScheduleUpdate( updates: WorkflowScheduleUpdate, requestId: string, context: string, - options: { expectedLastQueuedAt?: Date | null; allowCompleted?: boolean } = {} -): Promise { + options: { + expectedLastQueuedAt?: Date | null + allowCompleted?: boolean + /** + * Set at call sites that can transition the row to `disabled`. Presence both + * opts the site into the auto-disable email and adds a `status <> 'disabled'` + * guard, so the transition fires exactly once per disable. + */ + disableReason?: ScheduleDisableReason + /** Required inside a transaction, where mail must wait for commit. */ + deferNotification?: boolean + /** Join a caller's transaction instead of using the pooled client. */ + executor?: DbOrTx + } = {} +): Promise { + let outcome: ScheduleUpdateOutcome + try { const claimGuard = options.expectedLastQueuedAt === undefined @@ -162,7 +186,17 @@ async function applyScheduleUpdate( ? undefined : ne(workflowSchedule.status, 'completed') - const updatedRows = await db + /** + * `RETURNING` yields the NEW row, so `status === 'disabled'` alone only means + * "is disabled". Excluding rows that were already disabled makes a returned + * row a true `active -> disabled` edge. Scoped to disable-capable call sites + * so lock releases on already-disabled rows still work. + */ + const notAlreadyDisabled = options.disableReason + ? ne(workflowSchedule.status, 'disabled') + : undefined + + const updatedRows = await (options.executor ?? db) .update(workflowSchedule) .set(updates) .where( @@ -170,16 +204,29 @@ async function applyScheduleUpdate( eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt), claimGuard, - notCompletedGuard + notCompletedGuard, + notAlreadyDisabled ) ) - .returning({ id: workflowSchedule.id }) + .returning({ id: workflowSchedule.id, status: workflowSchedule.status }) - return updatedRows.length > 0 + const row = updatedRows[0] + outcome = { updated: Boolean(row), status: row?.status ?? null } } catch (error) { logger.error(`[${requestId}] ${context}`, error, { cause: describeError(error) }) throw error } + + // Outside the try: a mail failure must never surface as a schedule-tick fault. + if (options.disableReason && !options.deferNotification && outcome.status === 'disabled') { + await notifyScheduleAutoDisabled({ + scheduleId, + reason: options.disableReason, + requestId, + }) + } + + return outcome } export async function releaseScheduleLock( @@ -199,7 +246,41 @@ export async function releaseScheduleLock( updates.nextRunAt = nextRunAt } - return applyScheduleUpdate(scheduleId, updates, requestId, context, options) + const outcome = await applyScheduleUpdate(scheduleId, updates, requestId, context, options) + return outcome.updated +} + +/** + * Applies {@link buildScheduleFailureUpdate} through the same guarded write the + * trigger.dev path uses, and reports whether the row just transitioned to + * `disabled`. Callers own the notification so an in-transaction caller can defer + * it until after commit. + */ +export async function applyScheduleFailureUpdate(params: { + scheduleId: string + now: Date + nextRunAt: Date | null + expectedLastQueuedAt: Date + requestId: string + context: string + executor?: DbOrTx +}): Promise<{ updated: boolean; disabled: boolean }> { + const { scheduleId, now, nextRunAt, expectedLastQueuedAt, requestId, context, executor } = params + + const outcome = await applyScheduleUpdate( + scheduleId, + buildScheduleFailureUpdate(now, nextRunAt), + requestId, + context, + { + expectedLastQueuedAt, + disableReason: 'consecutive_failures', + deferNotification: true, + executor, + } + ) + + return { updated: outcome.updated, disabled: outcome.status === 'disabled' } } function getScheduleClaimedAt(payload: ScheduleExecutionPayload): Date | null { @@ -239,7 +320,7 @@ async function retryScheduleAfterInfraFailure({ buildScheduleFailureUpdate(now, nextRunAt), requestId, `Error updating schedule ${payload.scheduleId} after exhausted infrastructure retries`, - { expectedLastQueuedAt: claimedAt } + { expectedLastQueuedAt: claimedAt, disableReason: 'consecutive_failures' } ) return } @@ -664,10 +745,12 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const updateClaimedSchedule = ( updates: WorkflowScheduleUpdate, - context: string - ): Promise => + context: string, + disableReason?: ScheduleDisableReason + ): Promise => applyScheduleUpdate(payload.scheduleId, updates, requestId, context, { expectedLastQueuedAt: claimedAt, + disableReason, }) try { @@ -793,7 +876,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { status: 'disabled', ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after authentication error` + `Failed to disable schedule ${payload.scheduleId} after authentication error`, + 'authentication_error' ) return } @@ -810,7 +894,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { status: 'disabled', ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after authorization error` + `Failed to disable schedule ${payload.scheduleId} after authorization error`, + 'authorization_error' ) return } @@ -824,7 +909,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { status: 'disabled', ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after missing workflow` + `Failed to disable schedule ${payload.scheduleId} after missing workflow`, + 'workflow_not_found' ) return } @@ -863,7 +949,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { }) await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after usage limit check` + `Error updating schedule ${payload.scheduleId} after usage limit check`, + 'consecutive_failures' ) return } @@ -885,7 +972,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after preprocessing failure` + `Error updating schedule ${payload.scheduleId} after preprocessing failure`, + 'consecutive_failures' ) return } @@ -953,7 +1041,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { nextRunAt: null, ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after skip` + `Failed to disable schedule ${payload.scheduleId} after skip`, + 'invalid_schedule' ) return } @@ -983,7 +1072,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after failure` + `Error updating schedule ${payload.scheduleId} after failure`, + 'consecutive_failures' ) } catch (error: unknown) { logger.error( @@ -995,7 +1085,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after execution error` + `Error updating schedule ${payload.scheduleId} after execution error`, + 'consecutive_failures' ) } } catch (error: unknown) { @@ -1468,7 +1559,10 @@ export async function executeJobInline(payload: JobExecutionPayload) { }, requestId, `Error updating job ${payload.scheduleId} after failure`, - { expectedLastQueuedAt: now } + { + expectedLastQueuedAt: now, + disableReason: shouldDisable ? 'consecutive_failures' : undefined, + } ) } } diff --git a/apps/sim/components/emails/billing/index.ts b/apps/sim/components/emails/billing/index.ts index 9c8faaca3b0..eab1c008435 100644 --- a/apps/sim/components/emails/billing/index.ts +++ b/apps/sim/components/emails/billing/index.ts @@ -6,4 +6,5 @@ export { FreeTierUpgradeEmail } from './free-tier-upgrade-email' export { LimitThresholdEmail } from './limit-threshold-email' export { PaymentFailedEmail } from './payment-failed-email' export { PlanWelcomeEmail } from './plan-welcome-email' +export { UsageLimitReachedEmail } from './usage-limit-reached-email' export { UsageThresholdEmail } from './usage-threshold-email' diff --git a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx new file mode 100644 index 00000000000..8ff73ab52d9 --- /dev/null +++ b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx @@ -0,0 +1,81 @@ +import { Link, Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface UsageLimitReachedEmailProps { + userName?: string + planName: string + /** Drives the remedy copy and CTA label — org admins raise the org limit. */ + scope: 'user' | 'organization' + currentUsage: number + limit: number + ctaLink: string +} + +/** + * Sent at 100% of the usage limit to paid and organization accounts. The + * free-tier equivalent is `CreditsExhaustedEmail`, whose remedy is an upgrade + * rather than a limit change. + */ +export function UsageLimitReachedEmail({ + userName, + planName, + scope, + currentUsage, + limit, + ctaLink, +}: UsageLimitReachedEmailProps) { + const brand = getBrandConfig() + const isOrganization = scope === 'organization' + const previewText = `${brand.name}: You've reached your ${planName} usage limit` + + return ( + + + {userName ? `Hi ${userName},` : 'Hi,'} + + + + {isOrganization + ? `Your organization has reached its monthly usage limit on the ${planName} plan.` + : `You've reached your monthly usage limit on the ${planName} plan.`}{' '} + Workflow runs, knowledge base search, document uploads, and Chat are paused until the limit + goes up. + + +

+ Usage + + {dollarsToCredits(currentUsage).toLocaleString()} of{' '} + {dollarsToCredits(limit).toLocaleString()} credits used + +
+ + {/* Divider */} +
+ + + {isOrganization + ? 'Raise the organization usage limit in billing settings to resume.' + : 'Raise your usage limit in billing settings, or upgrade your plan, to resume.'} + + + + + {isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'} + + + + {/* Divider */} +
+ + + Sent to the people who manage billing for this account. + + + ) +} + +export default UsageLimitReachedEmail diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index ffbf4d11f22..a3c8bc50a93 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -8,6 +8,8 @@ export * from './billing' export * from './components' // Invitation emails export * from './invitations' +// Operational notification emails +export * from './notifications' // Render functions and subjects export * from './render' export * from './subjects' diff --git a/apps/sim/components/emails/notifications/index.ts b/apps/sim/components/emails/notifications/index.ts new file mode 100644 index 00000000000..c930a723ce9 --- /dev/null +++ b/apps/sim/components/emails/notifications/index.ts @@ -0,0 +1 @@ +export { ScheduleDisabledEmail } from './schedule-disabled-email' diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx new file mode 100644 index 00000000000..a5116be4246 --- /dev/null +++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx @@ -0,0 +1,83 @@ +import { Link, Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { + SCHEDULE_DISABLE_REASON_COPY, + type ScheduleDisableReason, +} from '@/lib/workflows/schedules/disable-reasons' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface ScheduleDisabledEmailProps { + recipientName?: string + /** Drives the noun and the CTA label. */ + kind: 'workflow' | 'job' + /** Workflow name or job title. Absent when the source row could not be read. */ + resourceName?: string + reason: ScheduleDisableReason + /** Consecutive failures at disable time. Rendered only for `consecutive_failures`. */ + failedCount?: number + /** Deep link to the workflow or scheduled tasks. Absent when the workspace is unknown. */ + manageLink?: string +} + +/** + * Sent when Sim turns a schedule off on its own — either after repeated + * failures or on a terminal error that cannot resolve itself. + */ +export function ScheduleDisabledEmail({ + recipientName, + kind, + resourceName, + reason, + failedCount, + manageLink, +}: ScheduleDisabledEmailProps) { + const brand = getBrandConfig() + const resourceLabel = + resourceName ?? (kind === 'job' ? 'a scheduled task' : 'a scheduled workflow') + const reasonCopy = + reason === 'consecutive_failures' && failedCount + ? `It failed ${failedCount.toLocaleString()} times in a row.` + : SCHEDULE_DISABLE_REASON_COPY[reason] + const previewText = `${brand.name} turned off the schedule for ${resourceLabel}` + + return ( + + + {recipientName ? `Hi ${recipientName},` : 'Hi,'} + + + + {brand.name} turned off the schedule for {resourceLabel}. It will not run again until you + turn it back on. + + +
+ Reason + {reasonCopy} +
+ + {/* Divider */} +
+ + Fix the problem, then turn the schedule back on. + + {manageLink ? ( + + + {kind === 'job' ? 'Open scheduled tasks' : 'Open workflow'} + + + ) : null} + + {/* Divider */} +
+ + + Sent to workspace admins and the person who created this schedule. + + + ) +} + +export default ScheduleDisabledEmail diff --git a/apps/sim/components/emails/render-notifications.test.ts b/apps/sim/components/emails/render-notifications.test.ts new file mode 100644 index 00000000000..67d45e0736a --- /dev/null +++ b/apps/sim/components/emails/render-notifications.test.ts @@ -0,0 +1,98 @@ +/** + * Executes the notification templates for real. Every other suite mocks the + * render functions, so without this nothing would catch a template that throws + * or silently drops its key copy. + * + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + renderScheduleDisabledEmail, + renderUsageLimitReachedEmail, +} from '@/components/emails/render' + +describe('renderScheduleDisabledEmail', () => { + it('renders the failure count for a threshold disable', async () => { + const html = await renderScheduleDisabledEmail({ + recipientName: 'John', + kind: 'workflow', + resourceName: 'Daily digest', + reason: 'consecutive_failures', + failedCount: 100, + manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456', + }) + + expect(html).toContain('Daily digest') + expect(html).toContain('It failed 100 times in a row.') + expect(html).toContain('Open workflow') + expect(html).toContain('https://sim.ai/workspace/ws_123/w/wf_456') + }) + + it('renders reason copy instead of a count for a single-strike disable', async () => { + const html = await renderScheduleDisabledEmail({ + kind: 'job', + resourceName: 'Weekly report', + reason: 'authentication_error', + manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks', + }) + + expect(html).toContain('could not be authenticated') + expect(html).not.toContain('times in a row') + expect(html).toContain('Open scheduled tasks') + }) + + it('falls back to a generic noun and omits the CTA when the source is unknown', async () => { + const html = await renderScheduleDisabledEmail({ + kind: 'workflow', + reason: 'workflow_not_found', + }) + + expect(html).toContain('a scheduled workflow') + expect(html).not.toContain('Open workflow') + }) +}) + +describe('renderUsageLimitReachedEmail', () => { + it('tells a personal payer to raise their limit, never to upgrade to Pro', async () => { + const html = await renderUsageLimitReachedEmail({ + userName: 'John', + planName: 'Pro', + scope: 'user', + currentUsage: 20, + limit: 20, + ctaLink: 'https://sim.ai/account/settings/billing', + }) + + expect(html).toContain('Raise Usage Limit') + expect(html).toContain('upgrade your plan') + expect(html).not.toContain('Upgrade to Pro') + expect(html).not.toContain('free credits') + }) + + it('points an organization at the org limit', async () => { + const html = await renderUsageLimitReachedEmail({ + planName: 'Team', + scope: 'organization', + currentUsage: 500, + limit: 500, + ctaLink: 'https://sim.ai/organization/org_1/settings/billing', + }) + + expect(html).toContain('Raise Organization Limit') + expect(html).toContain('organization usage limit') + expect(html).not.toContain('upgrade your plan') + }) + + it('renders credits, not dollars', async () => { + const html = await renderUsageLimitReachedEmail({ + planName: 'Pro', + scope: 'user', + currentUsage: 20, + limit: 20, + ctaLink: 'https://sim.ai/account/settings/billing', + }) + + expect(html).toContain('credits used') + expect(html).not.toContain('$20') + }) +}) diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 652d6e7df2a..2510be6cbc6 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -15,6 +15,7 @@ import { LimitThresholdEmail, PaymentFailedEmail, PlanWelcomeEmail, + UsageLimitReachedEmail, UsageThresholdEmail, } from '@/components/emails/billing' import { @@ -23,9 +24,11 @@ import { WorkspaceAddedEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' +import { ScheduleDisabledEmail } from '@/components/emails/notifications' import { HelpConfirmationEmail } from '@/components/emails/support' import type { UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' export { getEmailSubject, getLimitEmailSubject } from './subjects' @@ -136,6 +139,28 @@ export async function renderUsageThresholdEmail(params: { ) } +export async function renderUsageLimitReachedEmail(params: { + userName?: string + planName: string + scope: 'user' | 'organization' + currentUsage: number + limit: number + ctaLink: string +}): Promise { + return await render(UsageLimitReachedEmail(params)) +} + +export async function renderScheduleDisabledEmail(params: { + recipientName?: string + kind: 'workflow' | 'job' + resourceName?: string + reason: ScheduleDisableReason + failedCount?: number + manageLink?: string +}): Promise { + return await render(ScheduleDisabledEmail(params)) +} + export async function renderFreeTierUpgradeEmail(params: { userName?: string percentUsed: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index 8eeb9b06fdf..b5a4dc7ac71 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -21,6 +21,7 @@ export type EmailSubjectType = | 'credit-purchase' | 'abandoned-checkout' | 'free-tier-exhausted' + | 'schedule-disabled' | 'onboarding-followup' | 'welcome' @@ -69,6 +70,8 @@ export function getEmailSubject(type: EmailSubjectType): string { return `Quick question` case 'free-tier-exhausted': return `You've run out of free credits on ${brandName}` + case 'schedule-disabled': + return `A schedule was turned off on ${brandName}` case 'onboarding-followup': return `Quick question about ${brandName}` case 'welcome': diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index c855f49ea94..3d98e15e3c5 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -8,7 +8,14 @@ * * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' afterAll(() => { @@ -56,22 +63,52 @@ vi.mock('@/lib/billing/credits/daily-refresh', () => ({ getOrgMemberRefreshBounds: vi.fn(), })) +const { + mockGetEmailSubject, + mockGetLimitEmailSubject, + mockRenderCreditsExhausted, + mockRenderFreeTierUpgrade, + mockRenderUsageLimitReached, + mockRenderUsageThreshold, + mockSendEmail, + mockGetEmailPreferences, + mockIsOrgAdminRole, +} = vi.hoisted(() => ({ + mockGetEmailSubject: vi.fn(() => 'Subject'), + mockGetLimitEmailSubject: vi.fn(() => 'Limit subject'), + mockRenderCreditsExhausted: vi.fn(() => Promise.resolve('free')), + mockRenderFreeTierUpgrade: vi.fn(() => Promise.resolve('nudge')), + mockRenderUsageLimitReached: vi.fn(() => Promise.resolve('reached')), + mockRenderUsageThreshold: vi.fn(() => Promise.resolve('warning')), + mockSendEmail: vi.fn(() => Promise.resolve({ success: true })), + mockGetEmailPreferences: vi.fn(() => Promise.resolve(null as unknown)), + mockIsOrgAdminRole: vi.fn(() => true), +})) + vi.mock('@/components/emails', () => ({ - getEmailSubject: vi.fn(), - renderCreditsExhaustedEmail: vi.fn(), - renderFreeTierUpgradeEmail: vi.fn(), - renderUsageThresholdEmail: vi.fn(), + getEmailSubject: mockGetEmailSubject, + getLimitEmailSubject: mockGetLimitEmailSubject, + renderCreditsExhaustedEmail: mockRenderCreditsExhausted, + renderFreeTierUpgradeEmail: mockRenderFreeTierUpgrade, + renderUsageLimitReachedEmail: mockRenderUsageLimitReached, + renderUsageThresholdEmail: mockRenderUsageThreshold, })) vi.mock('@/lib/messaging/email/mailer', () => ({ - sendEmail: vi.fn(), + sendEmail: mockSendEmail, })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ - getEmailPreferences: vi.fn(), + getEmailPreferences: mockGetEmailPreferences, })) -import { getUserUsageLimit, syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: mockIsOrgAdminRole })) + +import { + getUserUsageLimit, + maybeSendUsageThresholdEmail, + syncUsageLimitsFromSubscription, +} from '@/lib/billing/core/usage' const PRO_SUBSCRIPTION = { id: 'sub-1', @@ -232,3 +269,159 @@ describe('syncUsageLimitsFromSubscription', () => { expect(expression).toContain('creditBalance') }) }) + +describe('maybeSendUsageThresholdEmail', () => { + const paidUser = { + scope: 'user' as const, + planName: 'Pro', + userId: 'user-1', + userEmail: 'user-1@example.com', + userName: 'Ada', + workspaceId: 'ws-1', + limit: 20, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isBillingEnabled: true }) + mockGetEmailPreferences.mockResolvedValue(null) + mockIsOrgAdminRole.mockReturnValue(true) + }) + + afterAll(() => { + resetEnvFlagsMock() + }) + + it('emails a paid personal account at 100% with the raise-your-limit template', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockRenderUsageLimitReached).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'user', planName: 'Pro' }) + ) + expect(mockRenderCreditsExhausted).not.toHaveBeenCalled() + expect(mockGetLimitEmailSubject).toHaveBeenCalledWith('credits', 'reached') + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('links a paid personal account to billing settings, not the upgrade page', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + const props = mockRenderUsageLimitReached.mock.calls[0]?.[0] as { ctaLink: string } + expect(props.ctaLink).toContain('/account/settings/billing') + }) + + it('fans out to org admins at 100% and skips non-admin members', async () => { + mockIsOrgAdminRole.mockImplementation((role: unknown) => role === 'admin') + queueTableRows(schemaMock.member, [ + { email: 'admin@example.com', name: 'Admin', enabled: null, role: 'admin' }, + { email: 'member@example.com', name: 'Member', enabled: null, role: 'member' }, + ]) + + await maybeSendUsageThresholdEmail({ + scope: 'organization', + planName: 'Team', + organizationId: 'org-1', + workspaceId: 'ws-1', + percentBefore: 95, + percentAfter: 100, + currentUsageAfter: 500, + limit: 500, + }) + + expect(mockSendEmail).toHaveBeenCalledTimes(1) + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'admin@example.com', emailType: 'notifications' }) + ) + expect(mockRenderUsageLimitReached).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'organization' }) + ) + }) + + it('still sends the free-tier template to a free personal account at 100%', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + planName: 'Free', + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 10, + limit: 10, + }) + + expect(mockRenderCreditsExhausted).toHaveBeenCalledTimes(1) + expect(mockRenderUsageLimitReached).not.toHaveBeenCalled() + expect(mockGetEmailSubject).toHaveBeenCalledWith('free-tier-exhausted') + }) + + it('sends only the reached email when one execution crosses 80 and 100 together', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 70, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockRenderUsageThreshold).not.toHaveBeenCalled() + expect(mockRenderUsageLimitReached).toHaveBeenCalledTimes(1) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('still sends the 80% warning to a paid account that has not reached 100%', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 70, + percentAfter: 85, + currentUsageAfter: 17, + }) + + expect(mockRenderUsageThreshold).toHaveBeenCalledTimes(1) + expect(mockRenderUsageLimitReached).not.toHaveBeenCalled() + }) + + it('does not send when the recipient unsubscribed from notifications', async () => { + mockGetEmailPreferences.mockResolvedValue({ unsubscribeNotifications: true }) + + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('does not send when the per-user billing toggle is off', async () => { + queueTableRows(schemaMock.settings, [{ enabled: false }]) + + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('does nothing when no threshold is crossed', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 50, + percentAfter: 60, + currentUsageAfter: 12, + }) + + expect(mockSendEmail).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 78b96fe8267..b68d1982623 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -6,8 +6,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, sql } from 'drizzle-orm' import { getEmailSubject, + getLimitEmailSubject, renderCreditsExhaustedEmail, renderFreeTierUpgradeEmail, + renderUsageLimitReachedEmail, renderUsageThresholdEmail, } from '@/components/emails' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' @@ -863,36 +865,18 @@ export async function maybeSendUsageThresholdEmail(params: { // Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge) const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80 - // Check for 100% threshold (free users only — credits exhausted) + // Check for 100% threshold — every plan and scope (usage limit reached) const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100 // Skip if no thresholds crossed if (!crosses80 && !crosses100) return - // For 80% threshold email (paid users only) - if (crosses80 && !isFreeUser) { - const ctaLink = billingSettingsLink - const sendTo = async (email: string, name?: string) => { - const prefs = await getEmailPreferences(email) - if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return - - const html = await renderUsageThresholdEmail({ - userName: name, - planName: params.planName, - percentUsed: Math.min(100, Math.round(params.percentAfter)), - currentUsage: params.currentUsageAfter, - limit: params.limit, - ctaLink, - }) - - await sendEmail({ - to: email, - subject: getEmailSubject('usage-threshold'), - html, - emailType: 'notifications', - }) - } - + /** + * Delivers to the account's notification recipients: the payer for personal + * scope, every org admin/owner for organization scope. Honors the per-user + * billing-notification toggle in both. + */ + const deliverToScope = async (send: (email: string, name?: string) => Promise) => { if (params.scope === 'user' && params.userId && params.userEmail) { const rows = await db .select({ enabled: settings.billingUsageNotificationsEnabled }) @@ -900,8 +884,11 @@ export async function maybeSendUsageThresholdEmail(params: { .where(eq(settings.userId, params.userId)) .limit(1) if (rows.length > 0 && rows[0].enabled === false) return - await sendTo(params.userEmail, params.userName) - } else if (params.scope === 'organization' && params.organizationId) { + await send(params.userEmail, params.userName) + return + } + + if (params.scope === 'organization' && params.organizationId) { const admins = await db .select({ email: user.email, @@ -915,19 +902,43 @@ export async function maybeSendUsageThresholdEmail(params: { .where(eq(member.organizationId, params.organizationId)) for (const a of admins) { - const isAdmin = isOrgAdminRole(a.role) - if (!isAdmin) continue + if (!isOrgAdminRole(a.role)) continue if (a.enabled === false) continue if (!a.email) continue - await sendTo(a.email, a.name || undefined) + await send(a.email, a.name || undefined) } } } + // !crosses100: one "reached" email, not a "nearing" and a "reached" in the same moment + if (crosses80 && !isFreeUser && !crosses100) { + const ctaLink = billingSettingsLink + await deliverToScope(async (email, name) => { + const prefs = await getEmailPreferences(email) + if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return + + const html = await renderUsageThresholdEmail({ + userName: name, + planName: params.planName, + percentUsed: Math.min(100, Math.round(params.percentAfter)), + currentUsage: params.currentUsageAfter, + limit: params.limit, + ctaLink, + }) + + await sendEmail({ + to: email, + subject: getEmailSubject('usage-threshold'), + html, + emailType: 'notifications', + }) + }) + } + // For 80% threshold email (free users only — skip if they also crossed 100% in same call) if (crosses80 && isFreeUser && !crosses100) { const upgradeLink = upgradeCreditsLink - const sendFreeTierEmail = async (email: string, name?: string) => { + await deliverToScope(async (email, name) => { const prefs = await getEmailPreferences(email) if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return @@ -952,56 +963,49 @@ export async function maybeSendUsageThresholdEmail(params: { currentUsage: params.currentUsageAfter, limit: params.limit, }) - } - - // Free users are always individual scope (not organization) - if (params.scope === 'user' && params.userId && params.userEmail) { - const rows = await db - .select({ enabled: settings.billingUsageNotificationsEnabled }) - .from(settings) - .where(eq(settings.userId, params.userId)) - .limit(1) - if (rows.length > 0 && rows[0].enabled === false) return - await sendFreeTierEmail(params.userEmail, params.userName) - } + }) } - // For 100% threshold email (free users only — credits exhausted) - if (crosses100 && isFreeUser) { - const upgradeLink = upgradeCreditsLink - const sendExhaustedEmail = async (email: string, name?: string) => { + // Paid and org accounts get raise-your-limit copy — upgrading is not their remedy + if (crosses100) { + const useFreeCopy = isFreeUser && params.scope === 'user' + + await deliverToScope(async (email, name) => { const prefs = await getEmailPreferences(email) if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return - const html = await renderCreditsExhaustedEmail({ - userName: name, - limit: params.limit, - upgradeLink, - }) + const html = useFreeCopy + ? await renderCreditsExhaustedEmail({ + userName: name, + limit: params.limit, + upgradeLink: upgradeCreditsLink, + }) + : await renderUsageLimitReachedEmail({ + userName: name, + planName: params.planName, + scope: params.scope, + currentUsage: params.currentUsageAfter, + limit: params.limit, + ctaLink: billingSettingsLink, + }) await sendEmail({ to: email, - subject: getEmailSubject('free-tier-exhausted'), + subject: useFreeCopy + ? getEmailSubject('free-tier-exhausted') + : getLimitEmailSubject('credits', 'reached'), html, emailType: 'notifications', }) - logger.info('Free tier credits exhausted email sent', { + logger.info('Usage limit reached email sent', { email, + scope: params.scope, + planName: params.planName, currentUsage: params.currentUsageAfter, limit: params.limit, }) - } - - if (params.scope === 'user' && params.userId && params.userEmail) { - const rows = await db - .select({ enabled: settings.billingUsageNotificationsEnabled }) - .from(settings) - .where(eq(settings.userId, params.userId)) - .limit(1) - if (rows.length > 0 && rows[0].enabled === false) return - await sendExhaustedEmail(params.userEmail, params.userName) - } + }) } } catch (error) { logger.error('Failed to send usage threshold email', { diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.test.ts b/apps/sim/lib/workflows/schedules/disable-notifications.test.ts new file mode 100644 index 00000000000..020105c8ccb --- /dev/null +++ b/apps/sim/lib/workflows/schedules/disable-notifications.test.ts @@ -0,0 +1,227 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetUrlsMock, + schemaMock, + urlsMockFns, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { sendEmailSpy, renderMock, subjectMock, getUsersWithPermissionsMock } = vi.hoisted(() => ({ + sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })), + renderMock: vi.fn(() => Promise.resolve('')), + subjectMock: vi.fn(() => 'A schedule was turned off'), + getUsersWithPermissionsMock: vi.fn(() => Promise.resolve([] as unknown[])), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) +vi.mock('@/components/emails', () => ({ + renderScheduleDisabledEmail: renderMock, + getEmailSubject: subjectMock, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUsersWithPermissions: getUsersWithPermissionsMock, +})) + +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' + +const WORKFLOW_SCHEDULE_ROW = { + sourceType: 'workflow', + jobTitle: null, + failedCount: 100, + sourceUserId: null, + sourceWorkspaceId: null, + workflowId: 'wf-1', + workflowName: 'Daily digest', + workflowUserId: 'creator-1', + workflowWorkspaceId: 'ws-1', +} + +const CREATOR = { email: 'creator@example.com', name: 'Ada' } + +function admin(email: string, name = 'Admin') { + return { userId: `u-${email}`, email, name, permissionType: 'admin' } +} + +beforeAll(() => { + urlsMockFns.mockGetBaseUrl.mockReturnValue('https://app.sim.ai') +}) + +afterAll(() => { + resetDbChainMock() + resetUrlsMock() +}) + +describe('notifyScheduleAutoDisabled', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + getUsersWithPermissionsMock.mockResolvedValue([]) + }) + + it('emails the creator and the workspace admins, one send per address', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([ + admin('admin-a@example.com'), + admin('admin-b@example.com'), + ]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(3) + for (const call of sendEmailSpy.mock.calls) { + // Never a `to` array — prepare.ts only checks to[0] for unsubscribe. + expect(typeof (call[0] as { to: unknown }).to).toBe('string') + expect(call[0]).toMatchObject({ emailType: 'notifications' }) + } + }) + + it('sends once when the creator is also a workspace admin', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([admin('CREATOR@example.com')]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + }) + + it('skips non-admin workspace members', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([ + { userId: 'u-2', email: 'writer@example.com', name: 'W', permissionType: 'write' }, + ]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'creator@example.com' }) + ) + }) + + it('resolves a job schedule through sourceUserId and links to scheduled tasks', async () => { + queueTableRows(schemaMock.workflowSchedule, [ + { + ...WORKFLOW_SCHEDULE_ROW, + sourceType: 'job', + jobTitle: 'Weekly report', + sourceUserId: 'job-owner', + sourceWorkspaceId: 'ws-9', + workflowName: null, + workflowUserId: null, + workflowWorkspaceId: null, + }, + ]) + queueTableRows(schemaMock.user, [CREATOR]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'authentication_error' }) + + expect(renderMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'job', + resourceName: 'Weekly report', + reason: 'authentication_error', + manageLink: 'https://app.sim.ai/workspace/ws-9/scheduled-tasks', + }) + ) + }) + + it('falls back to the creator alone when the workflow has no workspace', async () => { + queueTableRows(schemaMock.workflowSchedule, [ + { ...WORKFLOW_SCHEDULE_ROW, workflowWorkspaceId: null }, + ]) + queueTableRows(schemaMock.user, [CREATOR]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(getUsersWithPermissionsMock).not.toHaveBeenCalled() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ manageLink: undefined })) + }) + + it('sends nothing when the workflow row is gone (404 disable)', async () => { + queueTableRows(schemaMock.workflowSchedule, [ + { + ...WORKFLOW_SCHEDULE_ROW, + workflowName: null, + workflowUserId: null, + workflowWorkspaceId: null, + }, + ]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'workflow_not_found' }) + + expect(sendEmailSpy).not.toHaveBeenCalled() + }) + + it('sends nothing when the schedule row cannot be read', async () => { + queueTableRows(schemaMock.workflowSchedule, []) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).not.toHaveBeenCalled() + }) + + it('caps the fan-out at 20 recipients', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue( + Array.from({ length: 40 }, (_, i) => admin(`admin-${i}@example.com`)) + ) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(20) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'creator@example.com' }) + ) + }) + + it('keeps sending after one recipient fails', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([admin('admin-a@example.com')]) + sendEmailSpy.mockRejectedValueOnce(new Error('smtp down')) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + }) + + it('still emails the creator when the admin lookup fails', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockRejectedValue(new Error('db down')) + + await expect( + notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + ).resolves.toBeUndefined() + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'creator@example.com' }) + ) + }) + + it('still emails the admins when the creator lookup fails', async () => { + // First .limit() is the schedule row, second is the creator lookup. + dbChainMockFns.limit + .mockResolvedValueOnce([WORKFLOW_SCHEDULE_ROW]) + .mockRejectedValueOnce(new Error('db down')) + getUsersWithPermissionsMock.mockResolvedValue([admin('admin-a@example.com')]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'admin-a@example.com' }) + ) + }) +}) diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.ts b/apps/sim/lib/workflows/schedules/disable-notifications.ts new file mode 100644 index 00000000000..2bebe8bfd80 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/disable-notifications.ts @@ -0,0 +1,175 @@ +import { db } from '@sim/db' +import { user, workflow, workflowSchedule } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { getEmailSubject, renderScheduleDisabledEmail } from '@/components/emails' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' +import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' +import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('ScheduleDisableNotifications') + +/** + * Fan-out cap so one tick over a large workspace can't turn into hundreds of + * inline sends. The schedule's creator is always kept. + */ +const MAX_SCHEDULE_DISABLE_RECIPIENTS = 20 + +interface Recipient { + email: string + name: string | null +} + +/** + * Emails the schedule's creator and the workspace admins after Sim turns a + * schedule off on its own. + * + * Best-effort by contract: this never throws, because a mail failure must not + * fault the schedule tick that called it. + */ +export async function notifyScheduleAutoDisabled(params: { + scheduleId: string + reason: ScheduleDisableReason + requestId?: string +}): Promise { + const { scheduleId, reason, requestId } = params + + try { + const rows = await db + .select({ + sourceType: workflowSchedule.sourceType, + jobTitle: workflowSchedule.jobTitle, + failedCount: workflowSchedule.failedCount, + sourceUserId: workflowSchedule.sourceUserId, + sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, + workflowId: workflowSchedule.workflowId, + workflowName: workflow.name, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + }) + .from(workflowSchedule) + .leftJoin(workflow, eq(workflow.id, workflowSchedule.workflowId)) + .where(eq(workflowSchedule.id, scheduleId)) + .limit(1) + + const row = rows[0] + if (!row) { + logger.warn('Schedule auto-disabled but the row could not be read', { scheduleId, reason }) + return + } + + const isJob = row.sourceType === 'job' + const kind = isJob ? 'job' : 'workflow' + const ownerUserId = isJob ? row.sourceUserId : row.workflowUserId + const workspaceId = isJob ? row.sourceWorkspaceId : row.workflowWorkspaceId + const resourceName = (isJob ? row.jobTitle : row.workflowName) ?? undefined + + const recipients = await resolveRecipients(ownerUserId, workspaceId) + if (recipients.length === 0) { + logger.warn('Schedule auto-disabled but no recipients could be resolved', { + scheduleId, + reason, + workspaceId, + }) + return + } + + const manageLink = buildManageLink(workspaceId, isJob ? null : row.workflowId) + const subject = getEmailSubject('schedule-disabled') + + for (const recipient of recipients) { + try { + const html = await renderScheduleDisabledEmail({ + recipientName: recipient.name ?? undefined, + kind, + resourceName, + reason, + failedCount: row.failedCount, + manageLink, + }) + + await sendEmail({ + to: recipient.email, + subject, + html, + emailType: 'notifications', + }) + } catch (error) { + logger.error('Failed to send schedule-disabled email', error, { + scheduleId, + requestId, + }) + } + } + + logger.info('Sent schedule-disabled notification', { + scheduleId, + reason, + recipientCount: recipients.length, + requestId, + }) + } catch (error) { + logger.error('Failed to notify schedule auto-disable', error, { scheduleId, reason, requestId }) + } +} + +/** + * Creator first, then workspace admins. Deduped on lowercased email so someone + * who is both only receives one message. + * + * The two lookups are independent and each failure is contained: losing one + * must not zero out the other, or a blip in either turns an auto-disable back + * into the silent event this notification exists to prevent. + */ +async function resolveRecipients( + ownerUserId: string | null, + workspaceId: string | null +): Promise { + const recipients: Recipient[] = [] + const seen = new Set() + + const add = (email: string | null, name: string | null) => { + if (!email) return + const key = email.toLowerCase() + if (seen.has(key)) return + seen.add(key) + recipients.push({ email, name }) + } + + if (ownerUserId) { + try { + const ownerRows = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, ownerUserId)) + .limit(1) + add(ownerRows[0]?.email ?? null, ownerRows[0]?.name ?? null) + } catch (error) { + logger.error('Failed to resolve schedule creator for disable email', error, { ownerUserId }) + } + } + + if (workspaceId) { + try { + const members = await getUsersWithPermissions(workspaceId) + for (const member of members) { + if (member.permissionType !== 'admin') continue + add(member.email, member.name) + } + } catch (error) { + logger.error('Failed to resolve workspace admins for disable email', error, { workspaceId }) + } + } + + return recipients.slice(0, MAX_SCHEDULE_DISABLE_RECIPIENTS) +} + +function buildManageLink( + workspaceId: string | null, + workflowId: string | null +): string | undefined { + if (!workspaceId) return undefined + const base = `${getBaseUrl()}/workspace/${workspaceId}` + return workflowId ? `${base}/w/${workflowId}` : `${base}/scheduled-tasks` +} diff --git a/apps/sim/lib/workflows/schedules/disable-reasons.ts b/apps/sim/lib/workflows/schedules/disable-reasons.ts new file mode 100644 index 00000000000..d6d20e67591 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/disable-reasons.ts @@ -0,0 +1,23 @@ +/** + * Why Sim turned a schedule off. Threaded from the disable call site into the + * notification, because `workflow_schedule` has no column that records it. + */ +export type ScheduleDisableReason = + | 'consecutive_failures' + | 'authentication_error' + | 'authorization_error' + | 'workflow_not_found' + | 'invalid_schedule' + +/** + * User-facing sentence for each reason. Fixed copy rather than the underlying + * error string: the threshold paths carry arbitrary workflow-execution errors + * that can contain credentials or customer data. + */ +export const SCHEDULE_DISABLE_REASON_COPY: Record = { + consecutive_failures: 'It failed too many times in a row.', + authentication_error: 'The run could not be authenticated.', + authorization_error: 'The run was not authorized.', + workflow_not_found: 'The workflow could not be found.', + invalid_schedule: 'The schedule is no longer valid.', +} From c0e7ea9421c65f0c240955a37c13a6f9f9333a4e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:26:24 -0700 Subject: [PATCH 09/19] fix(pi): surface provider errors from agent events (#6133) Co-authored-by: Bill Leoutsakos --- .../handlers/pi/cloud-backend.test.ts | 44 ++++++++++++++++-- apps/sim/executor/handlers/pi/events.test.ts | 45 ++++++++++++++++++- apps/sim/executor/handlers/pi/events.ts | 17 ++++++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 34d78b14c96..20d485e0f5f 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -532,7 +532,43 @@ describe('runCloudPi', () => { return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) } if (command.includes('pi -p')) { - options.onStdout?.('{"type":"error","error":"model exploded"}\n') + options.onStdout?.( + `${[ + JSON.stringify({ + type: 'message_end', + message: { + role: 'assistant', + content: [{ type: 'text', text: '' }], + usage: { input: 0, output: 0, totalTokens: 0 }, + stopReason: 'error', + errorMessage: 'model rejected sk-byok', + }, + }), + JSON.stringify({ + type: 'turn_end', + message: { + role: 'assistant', + usage: { input: 0, output: 0, totalTokens: 0 }, + stopReason: 'error', + errorMessage: 'model rejected sk-byok', + }, + toolResults: [], + }), + JSON.stringify({ + type: 'agent_end', + willRetry: false, + messages: [ + { + role: 'assistant', + content: [{ type: 'text', text: '' }], + usage: { input: 0, output: 0, totalTokens: 0 }, + stopReason: 'error', + errorMessage: 'model rejected sk-byok', + }, + ], + }), + ].join('\n')}\n` + ) return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) } return Promise.resolve({ @@ -543,9 +579,11 @@ describe('runCloudPi', () => { } ) - await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/) + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow( + 'model rejected ***' + ) + expect(mockRun).toHaveBeenCalledTimes(2) expect(mockExecuteTool).not.toHaveBeenCalled() - expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) }) it('fails (no PR) when finalize reports neither no-changes nor a push', async () => { diff --git a/apps/sim/executor/handlers/pi/events.test.ts b/apps/sim/executor/handlers/pi/events.test.ts index c34e41549a3..1df73a40035 100644 --- a/apps/sim/executor/handlers/pi/events.test.ts +++ b/apps/sim/executor/handlers/pi/events.test.ts @@ -52,8 +52,51 @@ describe('normalizePiEvent', () => { ).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 }) }) + it('maps a settled agent failure to an error', () => { + expect( + normalizePiEvent({ + type: 'agent_end', + willRetry: false, + messages: [ + { + role: 'assistant', + stopReason: 'error', + errorMessage: 'Invalid API key', + }, + ], + }) + ).toEqual({ type: 'error', message: 'Invalid API key' }) + expect( + normalizePiEvent({ + type: 'agent_end', + messages: [{ role: 'assistant', stopReason: 'aborted' }], + }) + ).toEqual({ type: 'error', message: 'Pi request aborted' }) + }) + + it('does not fail an attempt that Pi will retry', () => { + expect( + normalizePiEvent({ + type: 'agent_end', + willRetry: true, + messages: [ + { + role: 'assistant', + stopReason: 'error', + errorMessage: 'Provider overloaded', + }, + ], + }) + ).toEqual({ type: 'other' }) + }) + it('maps agent_end to final and error to error', () => { - expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' }) + expect( + normalizePiEvent({ + type: 'agent_end', + messages: [{ role: 'assistant', stopReason: 'stop' }], + }) + ).toEqual({ type: 'final' }) expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({ type: 'error', message: 'boom', diff --git a/apps/sim/executor/handlers/pi/events.ts b/apps/sim/executor/handlers/pi/events.ts index 3686a23eb52..4d79683f09a 100644 --- a/apps/sim/executor/handlers/pi/events.ts +++ b/apps/sim/executor/handlers/pi/events.ts @@ -136,8 +136,23 @@ export function normalizePiEvent(raw: unknown): PiEvent | null { const usage = extractUsage(ev) return usage ? { type: 'usage', ...usage } : { type: 'other' } } - case 'agent_end': + case 'agent_end': { + if (ev.willRetry === true) return { type: 'other' } + const messages = Array.isArray(ev.messages) ? ev.messages : [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = asRecord(messages[index]) + if (!message || asString(message.role) !== 'assistant') continue + const stopReason = asString(message.stopReason) + if (stopReason === 'error' || stopReason === 'aborted') { + return { + type: 'error', + message: asString(message.errorMessage) || `Pi request ${stopReason}`, + } + } + break + } return { type: 'final' } + } case 'error': return { type: 'error', From 0bcf64a5058f4a8b41c12d9b2a47ba342335f019 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 31 Jul 2026 16:04:07 -0700 Subject: [PATCH 10/19] perf(bundle): stop three routes shipping the block and tool registries (#6138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public /integrations page downloaded, parsed and executed 19.54 MB of JavaScript, 15.52 MB of which was the tool registry — 4,351 tool configs, to render a catalog of names and icons. It arrived as `