diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md
index 9b6d6a87147..825a545e9d2 100644
--- a/.claude/rules/sim-list-ordering.md
+++ b/.claude/rules/sim-list-ordering.md
@@ -1,5 +1,5 @@
---
-description: List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action
+description: List and menu ordering that mirrors the toolbar or settings nav, encoded once, with one separator before the destructive action
paths:
- "apps/sim/app/**/*.tsx"
- "apps/sim/ee/**/*.tsx"
@@ -8,7 +8,7 @@ paths:
# List & Menu Ordering
-**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
+**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.
@@ -18,13 +18,14 @@ Before writing a list of items, find where the user sees those same items *first
| The list | Mirrors |
| --- | --- |
-| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down |
| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom |
| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down |
| A "New …" menu | the order those things appear once created |
Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last.
+Resource menus (`+` attach, `@` mention, resource-tab `+`) do not mirror the sidebar. Their order is a product decision encoded in `RESOURCE_MENU_ORDER` (see below), and every resource menu shares it.
+
Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.
## Grouping: a rule marks a change in what the action acts on
@@ -107,10 +108,10 @@ grouping wants the standard grouping.
An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu.
```ts
-/** Top-down order for every menu listing resource families, mirroring the sidebar. */
+/** Top-down order for every menu listing resource families. */
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
- 'integration', 'task', 'table', 'file', 'filefolder',
- 'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic',
+ 'integration', 'task', 'dashboard', 'table', 'file', 'filefolder',
+ 'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic',
]
export function byResourceMenuOrder(a: T, b: T) {
diff --git a/.cursor/rules/sim-list-ordering.mdc b/.cursor/rules/sim-list-ordering.mdc
index f85dc165a02..a1eb0b94af8 100644
--- a/.cursor/rules/sim-list-ordering.mdc
+++ b/.cursor/rules/sim-list-ordering.mdc
@@ -1,5 +1,5 @@
---
-description: "List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action"
+description: "List and menu ordering that mirrors the toolbar or settings nav, encoded once, with one separator before the destructive action"
globs: ["apps/sim/app/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/components/**/*.tsx"]
---
@@ -7,7 +7,7 @@ globs: ["apps/sim/app/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/components/**/*
# List & Menu Ordering
-**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
+**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.
@@ -17,13 +17,14 @@ Before writing a list of items, find where the user sees those same items *first
| The list | Mirrors |
| --- | --- |
-| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down |
| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom |
| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down |
| A "New …" menu | the order those things appear once created |
Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last.
+Resource menus (`+` attach, `@` mention, resource-tab `+`) do not mirror the sidebar. Their order is a product decision encoded in `RESOURCE_MENU_ORDER` (see below), and every resource menu shares it.
+
Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.
## Grouping: a rule marks a change in what the action acts on
@@ -106,10 +107,10 @@ grouping wants the standard grouping.
An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu.
```ts
-/** Top-down order for every menu listing resource families, mirroring the sidebar. */
+/** Top-down order for every menu listing resource families. */
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
- 'integration', 'task', 'table', 'file', 'filefolder',
- 'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic',
+ 'integration', 'task', 'dashboard', 'table', 'file', 'filefolder',
+ 'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic',
]
export function byResourceMenuOrder(a: T, b: T) {
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
index d8c2ddafde0..c9d4f92a539 100644
--- a/.github/workflows/desktop-e2e.yml
+++ b/.github/workflows/desktop-e2e.yml
@@ -14,6 +14,15 @@ on:
- 'apps/sim/app/layout.tsx'
- 'apps/sim/hooks/use-desktop-update-state.ts'
- 'apps/sim/lib/desktop/**'
+ - 'apps/sim/app/desktop/connect/**'
+ - 'apps/sim/app/credential-groups/**'
+ - 'apps/sim/hooks/queries/slack-search.ts'
+ - 'apps/sim/hooks/queries/personal-search-integrations.ts'
+ - 'apps/sim/hooks/use-search-integration-connection.ts'
+ - 'apps/sim/hooks/use-github-installation-setup.ts'
+ - 'apps/sim/app/o/**/integrations/indexed/use-member-enrollment.ts'
+ - 'apps/sim/lib/api/contracts/desktop-source-connect.ts'
+ - 'apps/sim/scripts/fixtures/desktop-source-connect.tsx'
- 'apps/sim/app/workspace/**/browser-session/**'
- 'apps/sim/app/_styles/**'
- 'apps/sim/lib/postcss/**'
@@ -61,6 +70,10 @@ jobs:
working-directory: apps/desktop
run: bun run build
+ - name: Install system-browser fixture
+ working-directory: apps/desktop
+ run: bunx playwright install chromium
+
- name: Run Playwright _electron smoke suite
working-directory: apps/desktop
run: bunx playwright test
@@ -98,6 +111,10 @@ jobs:
working-directory: apps/desktop
run: bun run build
+ - name: Install system-browser fixture
+ working-directory: apps/desktop
+ run: bunx playwright install chromium
+
- name: Run Playwright _electron smoke suite
working-directory: apps/desktop
run: bunx playwright test
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index 2a6defae3db..0188f97c2bf 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -172,6 +172,10 @@ jobs:
SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }}
run: bun run build
+ - name: Install system-browser fixture
+ working-directory: apps/desktop
+ run: bunx playwright install chromium
+
- name: Run Electron smoke tests
working-directory: apps/desktop
env:
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index efb2effe479..e62e49a6676 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -147,6 +147,60 @@ jobs:
if-no-files-found: ignore
retention-days: 7
+ - name: Verify Lucid MCP search and complete diagram reads over real HTTP
+ if: matrix.provision == 'push'
+ working-directory: apps/sim
+ env:
+ NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
+ NEXT_PUBLIC_FORCE_HOSTED: 'false'
+ SEARCH_LUCID_REPORT_PATH: ${{ runner.temp }}/search-lucid.json
+ run: bun scripts/test-search-lucid-e2e.ts
+
+ - name: Upload Lucid acceptance report
+ if: failure() && matrix.provision == 'push'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: search-lucid
+ path: ${{ runner.temp }}/search-lucid.json
+ if-no-files-found: ignore
+ retention-days: 7
+
+ - name: Verify Zoom search over real HTTP
+ if: matrix.provision == 'push'
+ working-directory: apps/sim
+ env:
+ NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
+ NEXT_PUBLIC_FORCE_HOSTED: 'false'
+ SEARCH_ZOOM_REPORT_PATH: ${{ runner.temp }}/search-zoom.json
+ run: bun scripts/test-search-zoom-e2e.ts
+
+ - name: Upload Zoom acceptance report
+ if: failure() && matrix.provision == 'push'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: search-zoom
+ path: ${{ runner.temp }}/search-zoom.json
+ if-no-files-found: ignore
+ retention-days: 7
+
+ - name: Verify Google Meet search over real HTTP
+ if: matrix.provision == 'push'
+ working-directory: apps/sim
+ env:
+ NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
+ NEXT_PUBLIC_FORCE_HOSTED: 'false'
+ SEARCH_GOOGLE_MEET_REPORT_PATH: ${{ runner.temp }}/search-google-meet.json
+ run: bun scripts/test-search-google-meet-e2e.ts
+
+ - name: Upload Google Meet acceptance report
+ if: failure() && matrix.provision == 'push'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: search-google-meet
+ path: ${{ runner.temp }}/search-google-meet.json
+ if-no-files-found: ignore
+ retention-days: 7
+
- name: Verify SCIM and administration over real HTTP
working-directory: apps/sim
env:
diff --git a/CLAUDE.md b/CLAUDE.md
index be75f097df3..70309e5dfce 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -91,7 +91,7 @@ The `'use client'` server boundary, the app/worker runtime env split, and featur
- **Components**: `'use client'` only for hooks or browser APIs. Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()` never `toSorted()` on client paths): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI.
- **State ownership**: React Query owns server data — never `useState` + `fetch`; shareable client view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks: `.claude/rules/sim-hooks.md`. Stores (`devtools`, `persist` only with an explicit `partialize` whitelist, workflow value invariants): `.claude/rules/sim-stores.md`. URL state: `.claude/rules/sim-url-state.md`.
- **Utils**: inline a helper with one consumer; create `utils.ts` when 2+ files share it — in `lib/` (app-wide) or `feature/utils/` (feature-scoped). Check `lib/` before writing a new one.
-- **Lists and menus** mirror the order the user already reads elsewhere (sidebar, toolbar), encoded in one exported order constant; a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`.
+- **Lists and menus** mirror the order the user already reads elsewhere (toolbar, settings nav), encoded in one exported order constant (resource menus share `RESOURCE_MENU_ORDER`, a product order that does not mirror the sidebar); a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`.
- **Caching**: `lru-cache` with a `max` ceiling, never a hand-rolled TTL `Map`; a lifecycle map is not a cache; cache the gate, never the credential: `.claude/rules/sim-caching.md`.
## API Contracts and Routes
diff --git a/apps/desktop/e2e/fixtures/browser-buffer.ts b/apps/desktop/e2e/fixtures/browser-buffer.ts
new file mode 100644
index 00000000000..e776657825d
--- /dev/null
+++ b/apps/desktop/e2e/fixtures/browser-buffer.ts
@@ -0,0 +1,2 @@
+/** Matches Next's browser Buffer polyfill when bundling application code with esbuild. */
+export { Buffer } from 'buffer'
diff --git a/apps/desktop/e2e/source-connect.spec.ts b/apps/desktop/e2e/source-connect.spec.ts
new file mode 100644
index 00000000000..c918a0bcfb7
--- /dev/null
+++ b/apps/desktop/e2e/source-connect.spec.ts
@@ -0,0 +1,732 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, _electron as electron, expect, test } from '@playwright/test'
+import { getErrorMessage } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import { generateShortId } from '@sim/utils/id'
+import { build } from 'esbuild'
+import postcss from 'postcss'
+import loadPostcssConfig from 'postcss-load-config'
+
+const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
+const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
+const FIXTURE = fileURLToPath(
+ new URL('../../sim/scripts/fixtures/desktop-source-connect.tsx', import.meta.url)
+)
+
+/** Real renderer, preload, main process, loopback, and a separate browser cookie jar. */
+test('source authorization returns to its desktop screen and refreshes live', async () => {
+ const reportPath =
+ process.env.DESKTOP_SOURCE_CONNECT_REPORT_PATH ?? test.info().outputPath('source-connect.json')
+ const checks: {
+ name: string
+ status: 'passed' | 'failed'
+ durationMs: number
+ error?: string
+ }[] = []
+ const check = async (name: string, action: () => Promise) => {
+ const started = Date.now()
+ try {
+ await test.step(name, action)
+ checks.push({ name, status: 'passed', durationMs: Date.now() - started })
+ } catch (error) {
+ checks.push({
+ name,
+ status: 'failed',
+ durationMs: Date.now() - started,
+ error: getErrorMessage(error),
+ })
+ throw error
+ }
+ }
+ const tickets = new Map()
+ const attempts = new Map()
+ const accountAttempts = new Map()
+ let accountConnected = false
+ let mcpAccountConnected = false
+ const startSessions: string[] = []
+ const callbackSessions: string[] = []
+ const githubAttempts = new Map()
+ const githubStartSessions: string[] = []
+ const githubInventorySessions: string[] = []
+ let nativeCredentialVisible = false
+ let installed = false
+ let holdSlackStart = false
+ let canceledSlackRequests = 0
+ const personalAttempts = new Map()
+ let personalInventoryFailed = false
+ let personalInventoryFailures = 0
+ let javascript = ''
+ let stylesheet = ''
+ let origin = ''
+ let app: Awaited> | undefined
+ let browser: Awaited> | undefined
+ const userData = mkdtempSync(join(tmpdir(), 'sim-source-connect-e2e-'))
+ const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? '/', origin || 'http://localhost')
+ const path = url.pathname
+ const session = request.headers.cookie?.includes('browser-fixture')
+ ? 'browser-fixture'
+ : 'desktop-fixture'
+ const json = (value: unknown, status = 200) => {
+ response.writeHead(status, { 'content-type': 'application/json' })
+ response.end(JSON.stringify(value))
+ }
+ const redirect = (target: string) => {
+ response.writeHead(303, { location: target })
+ response.end()
+ }
+ const body = async () => {
+ let text = ''
+ for await (const chunk of request) text += chunk.toString()
+ return JSON.parse(text)
+ }
+ if (path === '/fixture.js' || path === '/fixture.css') {
+ response.setHeader('content-type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
+ response.end(path.endsWith('.js') ? javascript : stylesheet)
+ return
+ }
+ if (path === '/api/organizations/fixture-organization/connected-accounts') {
+ json({
+ credentialGroup: null,
+ availableProviders: [],
+ availableMcpConnectors: [],
+ canManage: false,
+ indexingAvailable: true,
+ viewerMcpAccounts: mcpAccountConnected
+ ? [
+ {
+ credentialId: 'fixture-mcp-account',
+ displayName: 'Fixture MCP account',
+ mcpServerId: 'fixture-mcp',
+ status: 'active',
+ },
+ ]
+ : [],
+ viewerAccounts: accountConnected
+ ? [
+ {
+ credentialId: 'fixture-account',
+ displayName: 'Fixture account',
+ providerId: 'google-drive',
+ groupId: 'fixture-group',
+ optionId: 'fixture-option',
+ status: 'active',
+ },
+ ]
+ : [],
+ })
+ return
+ }
+ if (
+ path === '/api/organizations/fixture-organization/connected-accounts/connect' ||
+ path === '/api/users/me/organization-accounts/fixture-account/reconnect'
+ ) {
+ const input = request.method === 'POST' && path.endsWith('/connect') ? await body() : null
+ const completionId = input?.oauthCompletionId ?? url.searchParams.get('oauthCompletionId')
+ if (!completionId) {
+ json({ error: 'Missing completion ID' }, 400)
+ return
+ }
+ accountAttempts.set(completionId, { session, mcp: Boolean(input?.mcpServerId) })
+ json({
+ invitationLink: `${origin}/credential-groups/enroll/fixture-account-invitation`,
+ authorizationUrl: `${origin}/account-provider?completionId=${completionId}`,
+ })
+ return
+ }
+ if (path === '/account-callback') {
+ const completionId = url.searchParams.get('completionId') ?? ''
+ const attempt = accountAttempts.get(completionId)
+ if (attempt?.session !== session) {
+ json({ error: 'Wrong attempt' }, 403)
+ return
+ }
+ accountAttempts.delete(completionId)
+ const denied = url.searchParams.has('error')
+ if (!denied) {
+ if (attempt.mcp) mcpAccountConnected = true
+ else accountConnected = true
+ }
+ redirect(
+ `/credential-groups/complete?completionId=${completionId}&organizationId=fixture-organization${denied ? '&oauth=denied' : ''}`
+ )
+ return
+ }
+ if (path === '/api/auth/get-session') {
+ json({ user: { id: 'fixture-user' }, session: { id: session } })
+ return
+ }
+ if (path === '/api/desktop/source-connect') {
+ const { requestId, request: sourceRequest } = await body()
+ if (startSessions.length === 0) await sleep(5_500)
+ tickets.set(requestId, sourceRequest)
+ json({ requestId })
+ return
+ }
+ if (path === '/api/desktop/source-connect/consume') {
+ const { requestId } = await body()
+ const ticket = tickets.get(requestId)
+ tickets.delete(requestId)
+ json(ticket ?? { error: 'expired' }, ticket ? 200 : 404)
+ return
+ }
+ if (path === '/api/knowledge/slack/oauth') {
+ await body()
+ const state = generateShortId(32)
+ attempts.set(state, session)
+ startSessions.push(session)
+ if (holdSlackStart) {
+ response.on('close', () => {
+ if (!response.writableEnded) canceledSlackRequests++
+ })
+ return
+ }
+ json({ authorizationUrl: `${origin}/provider?state=${state}` })
+ return
+ }
+ if (path === '/api/knowledge/sim-search/personal-integrations') {
+ if (request.method === 'POST') {
+ const { oauthCompletionId } = await body()
+ personalAttempts.set(oauthCompletionId, { session, completed: false })
+ json({
+ success: true,
+ data: { url: `${origin}/personal-provider?completionId=${oauthCompletionId}` },
+ })
+ } else if (personalInventoryFailed) {
+ personalInventoryFailures++
+ json({ error: 'Inventory temporarily unavailable' }, 503)
+ } else {
+ const attempt = personalAttempts.get(url.searchParams.get('completionId') ?? '')
+ const connected = attempt?.completed === true
+ json({
+ success: true,
+ data: {
+ completedCredentialId: connected ? 'fixture-personal-account' : null,
+ connections: connected
+ ? [
+ {
+ name: 'Slack',
+ providerId: 'slack',
+ connectorType: 'slack',
+ description: '',
+ accounts: [
+ {
+ credentialId: 'fixture-personal-account',
+ displayName: 'Fixture',
+ status: 'connected',
+ action: null,
+ },
+ ],
+ connectionStatus: 'connected',
+ action: null,
+ },
+ ]
+ : [],
+ available: [
+ {
+ name: 'Slack',
+ description: '',
+ target: {
+ type: 'link',
+ provider: 'slack',
+ connectorType: 'slack',
+ connectionMode: 'live',
+ optionId: 'fixture-option',
+ },
+ },
+ ],
+ nextCursor: null,
+ },
+ })
+ }
+ return
+ }
+ if (path === '/personal-callback') {
+ const completionId = url.searchParams.get('completionId') ?? ''
+ const attempt = personalAttempts.get(completionId)
+ if (!attempt || attempt.session !== session) {
+ json({ error: 'Wrong attempt' }, 403)
+ return
+ }
+ attempt.completed = true
+ redirect(`/credential-groups/complete?completionId=${completionId}`)
+ return
+ }
+ if (path === '/api/knowledge/slack/oauth/callback') {
+ const state = url.searchParams.get('state') ?? ''
+ callbackSessions.push(session)
+ const ok = attempts.get(state) === session && url.searchParams.has('code')
+ attempts.delete(state)
+ if (ok) installed = true
+ const reason =
+ url.searchParams.get('error') === 'session_expired' ? '&reason=signin_required' : ''
+ redirect(`/credential-groups/slack-complete?state=${state}&ok=${ok}${reason}`)
+ return
+ }
+ if (
+ path ===
+ '/api/knowledge/00000000-0000-4000-8000-000000000001/connectors/fixture-connector/enroll'
+ ) {
+ if (url.searchParams.has('oauthCompletionId'))
+ json({ error: 'Direct account connection requires a Search source' }, 400)
+ else
+ json({
+ success: true,
+ data: { url: `${origin}/credential-groups/enroll/fixture-invitation` },
+ })
+ return
+ }
+ if (path === '/api/knowledge/github/setup') {
+ if (request.method === 'POST') {
+ const { setupId } = await body()
+ githubStartSessions.push(session)
+ githubAttempts.set(setupId, { session, completed: false })
+ json({ success: true, url: `${origin}/github-provider?setupId=${setupId}` })
+ } else {
+ const attempt = githubAttempts.get(url.searchParams.get('setupId') ?? '')
+ if (!attempt || attempt.session !== session) json({ error: 'Wrong session' }, 403)
+ else
+ json({
+ success: true,
+ data: attempt.completed
+ ? {
+ status: 'completed',
+ credential: { id: 'fixture-github-credential', displayName: 'Fixture GitHub' },
+ }
+ : { status: 'pending' },
+ })
+ }
+ return
+ }
+ if (path === '/api/organization-credentials/oauth') {
+ githubInventorySessions.push(session)
+ await sleep(500)
+ json({
+ credentials: nativeCredentialVisible
+ ? [
+ {
+ id: 'fixture-github-credential',
+ name: 'Fixture GitHub',
+ provider: 'github-repositories',
+ },
+ ]
+ : [],
+ })
+ return
+ }
+ if (path === '/api/organization-credentials') {
+ json({ credentials: [] })
+ return
+ }
+ if (path === '/github-callback') {
+ const setupId = url.searchParams.get('setupId') ?? ''
+ const attempt = githubAttempts.get(setupId)
+ if (!attempt || attempt.session !== session) {
+ json({ error: 'Wrong session' }, 403)
+ return
+ }
+ attempt.completed = true
+ redirect(`/credential-groups/complete?completionId=${setupId}`)
+ return
+ }
+ if (path === '/api/knowledge/slack') {
+ json({
+ sharedAppAvailable: true,
+ bots: [],
+ installations: installed
+ ? [
+ {
+ id: 'fixture-install',
+ credentialId: 'fixture-credential',
+ appId: 'fixture-app',
+ teamId: 'fixture-team',
+ teamName: 'Fixture',
+ appKind: 'shared',
+ enabled: true,
+ needsValidation: false,
+ lastOutcome: null,
+ lastEventAt: null,
+ },
+ ]
+ : [],
+ })
+ return
+ }
+ if (path === '/desktop/connect/complete') {
+ const params = new URLSearchParams({ state: url.searchParams.get('state') ?? '' })
+ if (url.searchParams.has('error')) params.set('error', url.searchParams.get('error')!)
+ if (url.searchParams.has('credentialId'))
+ params.set('credentialId', url.searchParams.get('credentialId')!)
+ redirect(`http://127.0.0.1:${url.searchParams.get('port')}/connect/callback?${params}`)
+ return
+ }
+ if (path.startsWith('/api/')) {
+ json({})
+ return
+ }
+ response.setHeader('content-type', 'text/html')
+ if (path === '/account-provider') {
+ response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
+ const completionId = url.searchParams.get('completionId') ?? ''
+ response.end(
+ `Authorize account Deny account `
+ )
+ return
+ }
+ if (path === '/personal-provider') {
+ response.end(
+ `Authorize personal Search `
+ )
+ return
+ }
+ if (path === '/github-provider') {
+ response.end(
+ `Authorize GitHub `
+ )
+ return
+ }
+ if (path === '/provider') {
+ const state = url.searchParams.get('state') ?? ''
+ response.end(
+ `Authorize Cancel Session expired `
+ )
+ return
+ }
+ if (path === '/desktop/done') {
+ response.end('Returned
')
+ return
+ }
+ if (path === '/' || path === '/home')
+ response.setHeader(
+ 'set-cookie',
+ 'better-auth.session_token=desktop-fixture; HttpOnly; SameSite=Lax; Path=/'
+ )
+ response.end(
+ '
'
+ )
+ })
+ try {
+ await check('launch the production source hook and native bridge', async () => {
+ const config = await loadPostcssConfig({}, SIM_DIR)
+ const cssPath = join(SIM_DIR, 'app/_styles/globals.css')
+ const css = await postcss(config.plugins).process(
+ `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
+ { from: cssPath }
+ )
+ const bundle = await build({
+ entryPoints: [FIXTURE],
+ bundle: true,
+ write: false,
+ outfile: test.info().outputPath('fixture.js'),
+ format: 'iife',
+ platform: 'browser',
+ tsconfig: join(SIM_DIR, 'tsconfig.json'),
+ external: ['node:async_hooks'],
+ inject: [fileURLToPath(new URL('./fixtures/browser-buffer.ts', import.meta.url))],
+ banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
+ define: { 'process.env.NODE_ENV': '"development"' },
+ })
+ javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? ''
+ stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Missing fixture address')
+ origin = `http://127.0.0.1:${address.port}`
+ app = await electron.launch({
+ args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
+ cwd: DESKTOP_DIR,
+ env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
+ })
+ await app.evaluate(({ shell }) => {
+ const global = globalThis as typeof globalThis & { openedUrls: string[] }
+ global.openedUrls = []
+ shell.openExternal = async (url) => {
+ global.openedUrls.push(url)
+ }
+ })
+ browser = await chromium.launch()
+ })
+ if (!app || !browser) throw new Error('Missing apps')
+ const shell = app
+ const page = await app.firstWindow()
+ const pageErrors: string[] = []
+ page.on('pageerror', (error) => pageErrors.push(error.message))
+ await page.reload()
+ await expect.poll(() => pageErrors).toEqual([])
+ const context = await browser.newContext()
+ await context.addCookies([
+ {
+ name: 'better-auth.session_token',
+ value: 'browser-fixture',
+ url: origin,
+ httpOnly: true,
+ sameSite: 'Lax',
+ },
+ ])
+ const external = await context.newPage()
+ const opened = () =>
+ shell.evaluate(() => (globalThis as typeof globalThis & { openedUrls: string[] }).openedUrls)
+ await check(
+ 'separate browser consent completes under its initiating session and refreshes desktop',
+ async () => {
+ await page.getByLabel('Source draft').fill('Preserved while connecting')
+ await page.getByRole('button', { name: 'Connect Slack' }).click()
+ await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1)
+ expect(page.url()).toBe(`${origin}/home`)
+ await expect(page.getByLabel('Connection')).toHaveText('pending')
+ await external.goto((await opened())[0])
+ await external.getByRole('link', { name: 'Authorize', exact: true }).click()
+ await expect(page.getByLabel('Connection')).toHaveText('success')
+ await expect(page.getByLabel('Accounts')).toHaveText('1')
+ await expect(page.getByLabel('Source draft')).toHaveValue('Preserved while connecting')
+ expect(startSessions).toEqual(['browser-fixture'])
+ expect(callbackSessions).toEqual(['browser-fixture'])
+ await expect(external).toHaveURL(`${origin}/desktop/done?kind=connect`)
+ }
+ )
+ await check(
+ 'denied authorization returns an actionable error without navigating desktop',
+ async () => {
+ await page.getByRole('button', { name: 'Connect Slack' }).click()
+ await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2)
+ await external.goto((await opened())[1])
+ await external.getByRole('link', { name: 'Cancel', exact: true }).click()
+ await expect(page.getByLabel('Connection')).toHaveText('error')
+ await expect(page.getByRole('alert')).toContainText('Try connecting again')
+ expect(page.url()).toBe(`${origin}/home`)
+ await expect(page.getByLabel('Accounts')).toHaveText('1')
+ }
+ )
+ await check(
+ 'native cancellation rejects a stale callback without disrupting the next request',
+ async () => {
+ await page.getByRole('button', { name: 'Connect Slack' }).click()
+ await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(3)
+ await external.goto((await opened())[2])
+ await external.getByRole('link', { name: 'Authorize', exact: true }).waitFor()
+ const staleCallback = await external
+ .getByRole('link', { name: 'Authorize', exact: true })
+ .getAttribute('href')
+ await expect(page.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(1)
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click()
+ await expect(page.getByLabel('Connection')).toHaveText('error')
+ const canceled = new URL((await opened())[2])
+ const probe = `http://127.0.0.1:${canceled.searchParams.get('port')}/connect/callback?state=${'x'.repeat(32)}`
+ await expect
+ .poll(() =>
+ fetch(probe).then(
+ () => false,
+ () => true
+ )
+ )
+ .toBe(true)
+ await page.getByRole('button', { name: 'Connect Slack' }).click()
+ await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(4)
+ await external.goto(`${origin}${staleCallback}`)
+ await expect(page.getByLabel('Connection')).toHaveText('pending')
+ await external.goto((await opened())[3])
+ await external.getByRole('link', { name: 'Authorize', exact: true }).click()
+ await expect(page.getByLabel('Connection')).toHaveText('success')
+ await expect(page.getByLabel('Source draft')).toHaveValue('Preserved while connecting')
+ }
+ )
+ await check(
+ 'GitHub setup stays in the browser session and verifies the returned credential in desktop',
+ async () => {
+ await page.getByRole('button', { name: 'Connect GitHub' }).click()
+ await expect.poll(async () => (await opened()).length).toBe(5)
+ await external.goto((await opened())[4])
+ await external.getByRole('link', { name: 'Authorize GitHub' }).click()
+ await expect(page.getByLabel('GitHub error')).toContainText('not available')
+ await expect(page.getByLabel('GitHub credential')).toHaveText('')
+ nativeCredentialVisible = true
+ await page.getByRole('button', { name: 'Connect GitHub' }).click()
+ await expect.poll(async () => (await opened()).length).toBe(6)
+ await external.goto((await opened())[5])
+ await external.getByRole('link', { name: 'Authorize GitHub' }).click()
+ await expect.poll(() => githubInventorySessions.length).toBe(2)
+ await expect(page.getByLabel('GitHub pending')).toHaveText('true')
+ await expect(page.getByLabel('GitHub credential')).toHaveText('fixture-github-credential')
+ expect(githubStartSessions).toEqual(['browser-fixture', 'browser-fixture'])
+ expect(githubInventorySessions).toEqual(['desktop-fixture', 'desktop-fixture'])
+ await expect(page.getByLabel('GitHub pending')).toHaveText('false')
+ expect(page.url()).toBe(`${origin}/home`)
+ }
+ )
+ await check('ordinary knowledge-base enrollment preserves its invitation step', async () => {
+ await page.getByRole('button', { name: 'Connect invited source' }).click()
+ await expect.poll(async () => (await opened()).length).toBe(7)
+ await external.goto((await opened())[6])
+ await external.getByRole('link', { name: 'Authorize invited source' }).click()
+ await expect(page.getByLabel('Enrollment pending')).toHaveText('false')
+ await expect(page.getByLabel('Enrollment error')).toHaveText('')
+ expect(page.url()).toBe(`${origin}/home`)
+ })
+ await check('browser sign-in failures retain recovery guidance on desktop', async () => {
+ await page.getByRole('button', { name: 'Connect Slack' }).click()
+ await expect.poll(async () => (await opened()).length).toBe(8)
+ await external.goto((await opened())[7])
+ await external.getByRole('link', { name: 'Session expired' }).click()
+ await expect(page.getByLabel('Connection')).toHaveText('error')
+ await expect(page.getByRole('alert')).toContainText('Sign in to Sim in your browser')
+ expect(page.url()).toBe(`${origin}/home`)
+ })
+ await check('managed accounts return through the desktop completion handoff', async () => {
+ await page.getByRole('button', { name: 'Connect MCP account', exact: true }).click()
+ await expect.poll(async () => (await opened()).length).toBe(9)
+ await external.goto((await opened())[8])
+ await external.getByRole('link', { name: 'Authorize account' }).click()
+ await expect(page.getByLabel('Account authorization', { exact: true })).toHaveText('success')
+ await expect(page.getByLabel('Account count')).toHaveText('1')
+ expect(page.url()).toBe(`${origin}/home`)
+ await expect(page.getByLabel('Source draft')).toHaveValue('Preserved while connecting')
+ })
+ const web = await context.newPage()
+ web.on('pageerror', (error) => pageErrors.push(error.message))
+ await web.goto(`${origin}/o/fixture-organization/integrations?search=fixture`)
+ await check(
+ 'web authorization preserves the origin and refreshes after an isolated provider window',
+ async () => {
+ accountConnected = false
+ mcpAccountConnected = false
+ await web.reload()
+ await web.getByLabel('Source draft').fill('Web draft retained')
+ await expect(web.getByLabel('Account count')).toHaveText('0')
+ const popupReady = context.waitForEvent('page')
+ await web.getByRole('button', { name: 'Connect account', exact: true }).click()
+ const popup = await popupReady
+ await popup.getByRole('link', { name: 'Authorize account' }).click()
+ await expect(web.getByLabel('Account count')).toHaveText('1')
+ await expect(web.getByLabel('Account authorization', { exact: true })).toHaveText('success')
+ await expect(web.getByLabel('Source draft')).toHaveValue('Web draft retained')
+ expect(web.url()).toBe(`${origin}/o/fixture-organization/integrations?search=fixture`)
+ }
+ )
+ await check('overlapping connect and reconnect preserve the active authorization', async () => {
+ const popupReady = context.waitForEvent('page')
+ await web.getByRole('button', { name: 'Connect account', exact: true }).click()
+ const popup = await popupReady
+ await popup.getByRole('link', { name: 'Authorize account' }).waitFor()
+ const pendingAttempts = accountAttempts.size
+ await web.getByRole('button', { name: 'Reconnect account', exact: true }).click()
+ await expect(web.getByLabel('Reconnect error')).toContainText('Finish or cancel')
+ expect(accountAttempts.size).toBe(pendingAttempts)
+ await expect(web.getByLabel('Account authorization', { exact: true })).toHaveText('pending')
+ await popup.getByRole('link', { name: 'Authorize account' }).click()
+ await expect(web.getByLabel('Account authorization', { exact: true })).toHaveText('success')
+ })
+ await check('web denial and cancellation leave the initiating page usable', async () => {
+ const popupReady = context.waitForEvent('page')
+ await web.getByRole('button', { name: 'Connect account', exact: true }).click()
+ const popup = await popupReady
+ await popup.getByRole('link', { name: 'Deny account' }).click()
+ await expect(web.getByLabel('Account error')).toContainText('canceled')
+ await popup.close()
+ await expect(web.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(0)
+ const nextPopupReady = context.waitForEvent('page')
+ await web.getByRole('button', { name: 'Connect account', exact: true }).click()
+ const nextPopup = await nextPopupReady
+ await nextPopup.getByRole('link', { name: 'Authorize account' }).waitFor()
+ await expect(web.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(1)
+ await web.getByRole('button', { name: 'Cancel', exact: true }).click()
+ expect(pageErrors).toEqual([])
+ await expect(web.getByLabel('Account error')).toContainText('canceled')
+ await expect(web.getByRole('button', { name: 'Connect account', exact: true })).toBeEnabled()
+ await expect(web.getByLabel('Account count')).toHaveText('1')
+ })
+ await check('reconnect uses the same completion lifecycle', async () => {
+ const popupReady = context.waitForEvent('page')
+ await web.getByRole('button', { name: 'Reconnect account', exact: true }).click()
+ const popup = await popupReady
+ await popup.getByRole('link', { name: 'Authorize account' }).click()
+ await expect(web.getByLabel('Reconnect status')).toHaveText('success')
+ await expect(web.getByLabel('Source draft')).toHaveValue('Web draft retained')
+ })
+ await check('blocked popups complete in the same tab and return to Integrations', async () => {
+ await web.evaluate(() => {
+ window.open = () => null
+ })
+ await web.getByRole('button', { name: 'Connect account', exact: true }).click()
+ await web.getByRole('link', { name: 'Authorize account' }).click()
+ await expect(web).toHaveURL(`${origin}/o/fixture-organization/integrations`)
+ await expect(web.getByLabel('Account count')).toHaveText('1')
+ })
+ await check('canceling Slack setup aborts the pending web HTTP request', async () => {
+ holdSlackStart = true
+ const starts = startSessions.length
+ try {
+ await web.getByRole('button', { name: 'Connect Slack', exact: true }).click()
+ await expect.poll(() => startSessions.length).toBe(starts + 1)
+ await web.getByRole('button', { name: 'Cancel Slack request', exact: true }).click()
+ await expect.poll(() => canceledSlackRequests).toBe(1)
+ await expect(web.getByRole('button', { name: 'Connect Slack', exact: true })).toBeEnabled()
+ } finally {
+ holdSlackStart = false
+ }
+ })
+ await check(
+ 'desktop Search preserves pending receipts after inventory failure and allows cancellation/retry',
+ async () => {
+ const previousOpens = (await opened()).length
+ await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
+ await expect.poll(async () => (await opened()).length).toBe(previousOpens + 1)
+ await external.goto((await opened())[previousOpens])
+ await external.getByRole('link', { name: 'Authorize personal Search' }).waitFor()
+ personalInventoryFailed = true
+ await external.getByRole('link', { name: 'Authorize personal Search' }).click()
+ await expect(external).toHaveURL(`${origin}/desktop/done?kind=connect`)
+ await expect.poll(() => personalInventoryFailures).toBeGreaterThan(0)
+ await expect(
+ page.getByRole('button', { name: 'Connect personal Search', exact: true })
+ ).toBeEnabled()
+ const receipt = () =>
+ page.evaluate(() => {
+ const entry = Object.entries(localStorage).find(([key]) =>
+ key.startsWith('sim.search-connection.')
+ )
+ if (!entry) return null
+ const attempt: { completionId: string; status: string; credentialId?: string } =
+ JSON.parse(entry[1])
+ return attempt
+ })
+ const pendingReceipt = await receipt()
+ expect(pendingReceipt).toMatchObject({ status: 'pending' })
+ await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
+ expect(await receipt()).toEqual(pendingReceipt)
+ await page.getByRole('button', { name: 'Cancel personal Search', exact: true }).click()
+ await expect
+ .poll(receipt)
+ .toMatchObject({ completionId: pendingReceipt?.completionId, status: 'failed' })
+ personalInventoryFailed = false
+ await page.getByRole('button', { name: 'Retry personal inventory', exact: true }).click()
+ await page.getByRole('button', { name: 'Connect personal Search', exact: true }).click()
+ await expect.poll(async () => (await opened()).length).toBe(previousOpens + 2)
+ const retryReceipt = await receipt()
+ expect(retryReceipt).toMatchObject({ status: 'pending' })
+ expect(retryReceipt?.completionId).not.toBe(pendingReceipt?.completionId)
+ await external.goto((await opened())[previousOpens + 1])
+ await external.getByRole('link', { name: 'Authorize personal Search' }).click()
+ await expect.poll(receipt).toMatchObject({
+ completionId: retryReceipt?.completionId,
+ status: 'connected',
+ credentialId: 'fixture-personal-account',
+ })
+ }
+ )
+ await page.screenshot({ path: test.info().outputPath('source-connect-desktop.png') })
+ } finally {
+ mkdirSync(dirname(reportPath), { recursive: true })
+ writeFileSync(reportPath, JSON.stringify({ checks }, null, 2))
+ await browser?.close()
+ await app?.close()
+ await new Promise((resolve) => {
+ server.close(() => resolve())
+ server.closeAllConnections()
+ })
+ rmSync(userData, { recursive: true, force: true })
+ }
+})
diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts
index 9c2890e38a6..b55193ce83b 100644
--- a/apps/desktop/src/main/handoff.test.ts
+++ b/apps/desktop/src/main/handoff.test.ts
@@ -162,6 +162,24 @@ describe('createHandoffManager', () => {
})
describe('connect handoff account pinning', () => {
+ it('keeps a source request correlated across the browser and native completion', async () => {
+ const deps = makeDeps()
+ const manager = createHandoffManager(deps, makeCallbacks())
+ try {
+ const requestId = manager.prepareSourceConnect()
+ expect(await manager.beginConnect('source', { sourceRequestId: requestId })).toBe(true)
+ const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0])
+ expect(landing.searchParams.get('sourceRequestId')).toBe(requestId)
+ expect(landing.searchParams.get('user')).toBe('user-1')
+ expect(manager.consumeConnect(landing.searchParams.get('state')!)).toEqual({
+ sourceRequestId: requestId,
+ })
+ expect(manager.consumeConnect(landing.searchParams.get('state')!)).toBeNull()
+ } finally {
+ manager.clear()
+ }
+ })
+
it('pins the connect flow to the account the app is signed in as', async () => {
// The OAuth flow runs in the browser under the BROWSER's session, which is
// a different row from the app's — without this the credential would attach
diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts
index c9295941377..caeb3a4b612 100644
--- a/apps/desktop/src/main/handoff.ts
+++ b/apps/desktop/src/main/handoff.ts
@@ -45,6 +45,7 @@ export interface HandoffCallback {
export interface ConnectHandoffCallback {
state: string
error?: string
+ credentialId?: string
}
export interface HandoffCallbacks {
@@ -66,6 +67,7 @@ export interface HandoffManagerDeps {
/** Optional scope a chip-initiated connect carries into /desktop/connect. */
export interface ConnectScope {
+ sourceRequestId?: string
workspaceId?: string
credentialId?: string
draftId?: string
@@ -77,6 +79,8 @@ export interface HandoffManager {
beginConnect(providerId: string, scope?: ConnectScope): Promise
consume(state: string, kind: HandoffKind): boolean
consumeConnect(state: string): ConnectScope | null
+ prepareSourceConnect(): string
+ cancelSourceConnect(requestId: string): boolean
clear(): void
}
@@ -94,6 +98,8 @@ export function createHandoffManager(
callbacks: HandoffCallbacks
): HandoffManager {
const now = deps.now ?? Date.now
+ let flowRevision = 0
+ let preparedSource: { requestId: string; expiresAt: number } | null = null
let loopbackServer: Server | null = null
let loopbackTimer: NodeJS.Timeout | undefined
let pending: {
@@ -139,12 +145,22 @@ export function createHandoffManager(
parse: (url) => {
const state = url.searchParams.get('state') ?? ''
const error = url.searchParams.get('error')
- if (!STATE_PATTERN.test(state) || (error !== null && !ERROR_SLUG_PATTERN.test(error))) {
+ const credentialId = url.searchParams.get('credentialId')
+ if (
+ !STATE_PATTERN.test(state) ||
+ (error !== null && !ERROR_SLUG_PATTERN.test(error)) ||
+ (credentialId !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(credentialId))
+ ) {
return null
}
return {
state,
- dispatch: () => callbacks.onConnect({ state, ...(error !== null ? { error } : {}) }),
+ dispatch: () =>
+ callbacks.onConnect({
+ state,
+ ...(error !== null ? { error } : {}),
+ ...(credentialId ? { credentialId } : {}),
+ }),
}
},
},
@@ -210,7 +226,11 @@ export function createHandoffManager(
})
} catch (error) {
logger.error('Could not start the loopback server', { error })
- loopbackServer = null
+ if (loopbackServer === server) loopbackServer = null
+ return undefined
+ }
+ if (loopbackServer !== server) {
+ server.close()
return undefined
}
loopbackTimer = setTimeout(stopLoopback, HANDOFF_TTL_MS)
@@ -219,6 +239,8 @@ export function createHandoffManager(
}
const clear = () => {
+ flowRevision++
+ preparedSource = null
stopLoopback()
pending = null
}
@@ -241,21 +263,34 @@ export function createHandoffManager(
params: Record,
connectScope?: ConnectScope
): Promise => {
+ if (pending?.connectScope?.sourceRequestId)
+ callbacks.onConnect({ state: pending.state, error: 'superseded' })
+ const revision = ++flowRevision
const state = generateShortId(STATE_LENGTH)
// startLoopback() already tore down any prior server; if this bind fails,
// clear the now-orphaned pending so a superseded flow can't linger as a
// dangling entry pointing at a server that no longer exists.
const port = await startLoopback()
+ if (revision !== flowRevision) return false
if (!port) {
clear()
return false
}
+ preparedSource = null
pending = {
state,
createdAt: now(),
kind,
...(connectScope ? { connectScope: { ...connectScope } } : {}),
}
+ if (connectScope?.sourceRequestId) {
+ clearTimeout(loopbackTimer)
+ loopbackTimer = setTimeout(() => {
+ if (pending?.state !== state) return
+ callbacks.onConnect({ state, error: 'expired' })
+ clear()
+ }, 10 * 60_000)
+ }
const landing = new URL(landingPath, deps.origin())
for (const [key, value] of Object.entries(params)) {
landing.searchParams.set(key, value)
@@ -264,7 +299,7 @@ export function createHandoffManager(
landing.searchParams.set('port', String(port))
deps.events.record(kind === 'login' ? 'handoff_started' : 'connect_handoff_started')
const opened = await deps.openExternal(landing.toString())
- if (!opened) {
+ if (!opened && pending?.state === state) {
clear()
}
return opened
@@ -285,7 +320,18 @@ export function createHandoffManager(
// of quietly attaching the credential to the wrong account. Omitted when
// unknown (offline, signed out): the page then falls back to its normal
// login redirect rather than blocking a connect on a failed probe.
+ if (
+ scope.sourceRequestId &&
+ (preparedSource?.requestId !== scope.sourceRequestId || preparedSource.expiresAt <= now())
+ )
+ return false
+ const revision = ++flowRevision
const userId = await deps.currentUserId()
+ if (
+ revision !== flowRevision ||
+ (scope.sourceRequestId && (!userId || preparedSource?.requestId !== scope.sourceRequestId))
+ )
+ return false
return beginFlow(
'connect',
'/desktop/connect',
@@ -295,6 +341,7 @@ export function createHandoffManager(
...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
...(scope.credentialId ? { credentialId: scope.credentialId } : {}),
...(scope.draftId ? { draftId: scope.draftId } : {}),
+ ...(scope.sourceRequestId ? { sourceRequestId: scope.sourceRequestId } : {}),
},
scope
)
@@ -306,6 +353,24 @@ export function createHandoffManager(
const consumed = consumePending(state, 'connect')
return consumed ? { ...(consumed.connectScope ?? {}) } : null
},
+ prepareSourceConnect() {
+ if (pending?.connectScope?.sourceRequestId)
+ callbacks.onConnect({ state: pending.state, error: 'superseded' })
+ clear()
+ const requestId = generateShortId(32)
+ preparedSource = { requestId, expiresAt: now() + 10 * 60_000 }
+ return requestId
+ },
+ cancelSourceConnect(requestId: string) {
+ if (preparedSource?.requestId === requestId) {
+ clear()
+ return true
+ }
+ if (pending?.connectScope?.sourceRequestId !== requestId) return false
+ callbacks.onConnect({ state: pending.state, error: 'cancelled' })
+ clear()
+ return true
+ },
clear,
}
}
@@ -469,6 +534,8 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow {
export interface ConnectHandoffResult {
ok: boolean
error?: string
+ sourceRequestId?: string
+ credentialId?: string
/** Exact Mothership chat attempt, or null for ordinary integration flows. */
chatAttemptId: string | null
}
@@ -512,15 +579,25 @@ export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow {
if (callback.error === undefined) {
deps.events.record('connect_handoff_ok')
deps.focusMainWindow()
- deps.notifyRenderer({ ok: true, chatAttemptId: scope.chatAttemptId ?? null })
+ deps.notifyRenderer({
+ ok: true,
+ chatAttemptId: scope.chatAttemptId ?? null,
+ ...(scope.sourceRequestId
+ ? {
+ sourceRequestId: scope.sourceRequestId,
+ ...(callback.credentialId ? { credentialId: callback.credentialId } : {}),
+ }
+ : {}),
+ })
return
}
deps.events.record('connect_handoff_error', { error: callback.error })
- deps.focusMainWindow()
+ if (!['cancelled', 'superseded', 'expired'].includes(callback.error)) deps.focusMainWindow()
deps.notifyRenderer({
ok: false,
error: callback.error,
chatAttemptId: scope.chatAttemptId ?? null,
+ ...(scope.sourceRequestId ? { sourceRequestId: scope.sourceRequestId } : {}),
})
},
}
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index b3a4618d62b..9b53f1815f3 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -830,6 +830,8 @@ function main(): void {
},
},
beginOAuthConnect: (providerId, scope) => connectFlow.beginConnectHandoff(providerId, scope),
+ prepareSourceConnect: () => handoff.prepareSourceConnect(),
+ cancelSourceConnect: (requestId) => handoff.cancelSourceConnect(requestId),
updates: {
getState: () => updater?.getState() ?? { status: 'idle' },
check: () => updater?.check(),
diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts
index c4885d5d70b..20769df0376 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -294,6 +294,8 @@ describe('registerIpcHandlers', () => {
isLocalPageUrl,
retryLoad: vi.fn(),
beginOAuthConnect: vi.fn(async () => true),
+ prepareSourceConnect: vi.fn(() => 's'.repeat(32)),
+ cancelSourceConnect: vi.fn(() => true),
localFilesystem: new LocalFilesystemService({
chooseDirectory: vi.fn(async () => null),
}),
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index 4b2fb02f087..b7a16b1d278 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -155,6 +155,7 @@ function isDesktopToolCallId(raw: unknown): raw is string {
}
export interface OAuthConnectScope {
+ sourceRequestId?: string
workspaceId?: string
credentialId?: string
draftId?: string
@@ -361,6 +362,8 @@ export interface IpcDeps {
) => boolean
}
beginOAuthConnect: (providerId: string, scope: OAuthConnectScope) => Promise
+ prepareSourceConnect: () => string
+ cancelSourceConnect: (requestId: string) => boolean
updates: {
getState: () => DesktopUpdateState
check: () => void
@@ -717,6 +720,34 @@ export function registerIpcHandlers(deps: IpcDeps): void {
return deps.beginOAuthConnect(providerId, parsedScope)
},
},
+ 'desktop:source-connect-prepare': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requiresAccountData: true,
+ needsUserActivation: true,
+ denied: null,
+ handler: () => deps.prepareSourceConnect(),
+ },
+ 'desktop:source-connect': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requiresAccountData: true,
+ denied: false,
+ handler: (requestId) =>
+ typeof requestId === 'string' && /^[A-Za-z0-9_-]{32}$/.test(requestId)
+ ? deps.beginOAuthConnect('source', { sourceRequestId: requestId })
+ : false,
+ },
+ 'desktop:source-connect-cancel': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requiresAccountData: true,
+ denied: false,
+ handler: (requestId) =>
+ typeof requestId === 'string' && /^[A-Za-z0-9_-]{32}$/.test(requestId)
+ ? deps.cancelSourceConnect(requestId)
+ : false,
+ },
'desktop:local-files': {
kind: 'invoke',
gate: 'app-origin',
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 50fe5d3ff25..00fb6180a9b 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -127,6 +127,12 @@ const api: SimDesktopApi = {
: {}),
beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise =>
ipcRenderer.invoke('desktop:oauth-connect', providerId, scope),
+ prepareSourceConnect: (): Promise =>
+ ipcRenderer.invoke('desktop:source-connect-prepare'),
+ beginSourceConnect: (requestId: string): Promise =>
+ ipcRenderer.invoke('desktop:source-connect', requestId),
+ cancelSourceConnect: (requestId: string): Promise =>
+ ipcRenderer.invoke('desktop:source-connect-cancel', requestId),
onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => {
const listener = (_event: unknown, result: DesktopOAuthConnectResult) => callback(result)
ipcRenderer.on('desktop:oauth-connect-complete', listener)
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index 01ba721eec7..ef5cc9b3895 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -1,6 +1,19 @@
import type { SVGProps } from 'react'
import { useId } from 'react'
+interface LucidIconProps extends SVGProps {}
+
+export function LucidIcon(props: LucidIconProps) {
+ return (
+
+
+
+
+
+
+ )
+}
+
export function EnrichmentIcon(props: SVGProps) {
return (
/api/auth/oauth2/callback/google-meet
+```
+
+The existing Google Meet connection includes `meetings.space.readonly`, which authorizes these reads, and `meetings.space.created` for workflow actions. Search only reads conference records and artifacts. Google Workspace administrators may need to approve the app.
+
+These existing Meet scopes are [sensitive](https://developers.google.com/workspace/meet/api/guides/authenticate-authorize). A self-hosted OAuth app serving external users may need Google verification.
+
+## Recent transcripts and notes
+
+Use plain words or a phrase, optionally with meeting start dates. `kind: transcript` searches finalized transcript text and participant names. `kind: smart_notes` finds generated-note metadata and a Google Docs link; it does not search or return the note body. Omit the kind to search both. `project` can narrow to a known `spaces/ID` or meeting code.
+
+Meet has no title or full-text search endpoint. Sim matches terms locally within at most 3 recent conferences and 5 finalized artifacts per call. Results explicitly report bounded coverage. Use a narrow date range or known meeting space; missing results do not establish that a meeting or phrase is absent. Boolean operators, ownership filters and modification-date filters are unsupported. Dates use the actual conference start; the upper bound is exclusive.
+
+Reads retrieve complete finalized transcripts within the request, entry and byte limits. Speech stays attributed to the participant and timestamp. Sim rejects incomplete reads instead of presenting truncated text as a complete transcript.
+
+[Meet conference records](https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords) and [transcript entries](https://developers.google.com/workspace/meet/api/guides/artifacts) expire 30 days after the conference ends. Transcription or note-taking must have been enabled during the meeting. Sim does not generate a missing transcript or process recording audio.
+
+## Saved documents and scheduled meetings
+
+Connect **Google Drive** to read saved meeting notes and transcripts, including older documents that remain in the organizer's Drive. Use Drive's native query syntax, such as `fullText contains 'rollback' and mimeType = 'application/vnd.google-apps.document'`, then read the result. Drive dates mean file modification time, not the meeting date. Normal document sharing and retention rules apply.
+
+Use **Google Calendar** to search scheduled meetings and invitations. An event on the calendar does not establish that a Meet transcript or recording exists.
+
+## Disconnect and troubleshoot
+
+To disconnect, open **Integrations**, use the **…** menu beside Google Meet, choose **Disconnect** for your account, and confirm. Any workflows using that connection also lose access. This does not delete transcripts or notes from Google Drive.
+
+- **Connect is unavailable or permission is denied:** ask your Sim administrator to enable the source and finish Google OAuth setup. Your Google Workspace administrator may need to approve the app.
+- **Reconnect needed:** use **Reconnect** beside Google Meet and authorize the account again.
+- **No matching transcript:** narrow to the meeting's dates or space, confirm transcription was enabled, and check whether the conference is within the API's 30-day window. Use Google Drive for saved or older documents and the bodies of smart notes.
+
+For help, contact [help@sim.ai](mailto:help@sim.ai).
diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx
index 32d680b00a7..cb498ac484c 100644
--- a/apps/docs/content/docs/search/index.mdx
+++ b/apps/docs/content/docs/search/index.mdx
@@ -32,19 +32,27 @@ In Sources, open an integration to manage its connection and resource settings.
## Connector guides
-These nine providers support live Search. Google Docs, Sheets, and Slides are accessed through Drive. Other [knowledge-base connectors](/knowledgebase/connectors) do not automatically become Search integrations.
+These providers support live Search. Google Docs, Sheets, and Slides are accessed through Drive. Other [knowledge-base connectors](/knowledgebase/connectors) do not automatically become Search integrations.
| Source | Search path | Modes |
| --- | --- | --- |
| [Coda](/search/coda) | Personal Coda MCP; legacy REST connections search document titles | Member or service |
| [Confluence](/search/confluence) | Confluence Cloud CQL and content APIs | Member or service |
-| [GitHub](/search/github) | GitHub issue, code, and repository search | Member or GitHub App |
+| [Fireflies](/search/fireflies) | Meeting titles and spoken transcripts | Member only |
+| [GitHub](/search/github) | Repositories, code, issues and pull request discussions | Member or GitHub App |
| [GitLab](/search/gitlab) | Configured self-managed project's search and read APIs | Service only; admin or CSV permissions |
-| [Gmail](/search/gmail) | Gmail message search and message reads | Member or service |
+| [Gmail](/search/gmail) | Message search and conversation reads | Member or service |
| [Google Calendar](/search/google-calendar) | Calendar lists and event APIs | Member or service |
-| [Google Drive](/search/google-drive) | Drive search plus supported file reads/exports | Member or service |
+| [Google Drive](/search/google-drive) | File search, supported document reads and comments | Member or service |
+| [Google Meet](/search/google-meet) | Recent conference transcripts and generated-note links | Member only |
+| [Granola](/search/granola) | Semantic meeting search with source notes and transcript reads | Member only |
+| [HubSpot](/search/hubspot) | Contacts, companies, deals and tickets | Member only |
| [Jira](/search/jira) | Jira Cloud JQL and issue APIs | Member only |
+| [Linear](/search/linear) | Issues and their comment discussions | Member only |
+| [Lucid](/search/lucid) | Lucidchart diagrams and Lucidspark boards | Member only |
+| [Notion](/search/notion) | Page and database content through Notion MCP | Member only |
| [Slack](/search/slack) | Slack real-time search with the member's user token | Member only |
+| [Zoom](/search/zoom) | Past meetings with available transcripts, notes and summaries | Member only |
[Generic Secrets](/search/generic-secrets) is also available as a source, but does not add searchable documents. Organization mode makes its secrets available across the organization; Member mode lets each person manage their own secrets in Integrations. Build and Plan can use these secrets for requests; Search cannot mount them.
diff --git a/apps/docs/content/docs/search/lucid.mdx b/apps/docs/content/docs/search/lucid.mdx
new file mode 100644
index 00000000000..4012bff1d9b
--- /dev/null
+++ b/apps/docs/content/docs/search/lucid.mdx
@@ -0,0 +1,24 @@
+---
+title: Lucid
+description: Search Lucidchart diagrams and Lucidspark boards with your own Lucid account
+---
+
+## Connect
+
+An administrator enables **Lucid → Member accounts** in **Settings → Sources**. Each person then connects Lucid in **Integrations** and approves access with their own Lucid account.
+
+Sim uses [Lucid’s official read-only MCP server](https://help.lucid.co/hc/en-us/articles/51290793390740-View-and-search-Lucid-content-with-AI-tools-using-the-Lucid-read-only-MCP-server), available on Free, Individual, Team and Enterprise plans; FedRAMP accounts are not supported. No developer app, API key or additional Sim environment variables are needed. Team and Enterprise administrators may need to enable MCP access in Lucid. Lucid excludes documents owned outside the connected account, even when shared with you.
+
+## Search
+
+Search requires terms, even when filtering by date. Results are ranked for relevance and may not match the title literally. Start with a short document-title query, such as `deployment architecture`. Select `lucidchart` or `lucidspark` to narrow the product, or search both. Read a result to inspect its diagram or board structure.
+
+To find text inside a known document, use its UUID or Lucid URL as the native query’s `project` and enter a literal phrase such as `API Gateway`. This matches shape labels and sticky-note text, case-insensitively. It does not search notes, tags, links or comments.
+
+Title queries allow up to 400 characters; document-scoped text queries allow 200. Boolean and field operators are unsupported. Document search has no continuation and verifies at most 10 candidates. Dates use modification time; filtering and sorting the returned candidates cannot establish the newest or oldest document across the entire account.
+
+## Diagram content
+
+Reads preserve Lucid’s structured pages, nodes, connections and properties, with links back to the source. Large responses can be read in successive windows of the same document version. Sim rejects incomplete or changed documents rather than treating a partial graph as complete.
+
+A complete read is limited to 8 page regions and 512 KiB. Images and external links remain references; rendered diagrams, OCR, comments and Lucidscale are not included. See [Lucid’s MCP access rules](https://help.lucid.co/hc/en-us/articles/53780064823188-For-admins-Lucid-MCP-server-security-and-authorization) for account restrictions.
diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json
index b9a091a9ca9..44cafa1b8ad 100644
--- a/apps/docs/content/docs/search/meta.json
+++ b/apps/docs/content/docs/search/meta.json
@@ -12,11 +12,14 @@
"gmail",
"google-calendar",
"google-drive",
+ "google-meet",
"granola",
"hubspot",
"jira",
"linear",
+ "lucid",
"notion",
- "slack"
+ "slack",
+ "zoom"
]
}
diff --git a/apps/docs/content/docs/search/zoom.mdx b/apps/docs/content/docs/search/zoom.mdx
new file mode 100644
index 00000000000..01ad72daec6
--- /dev/null
+++ b/apps/docs/content/docs/search/zoom.mdx
@@ -0,0 +1,55 @@
+---
+title: Zoom
+description: Search past meetings, transcripts, personal notes and AI summaries with your Zoom account
+---
+
+## Connect
+
+An administrator enables **Zoom → Member accounts** under **Settings → Sources**. Then each person connects their own account:
+
+1. Open **Integrations** in Sim and click **Connect** beside Zoom.
+2. Sign in to Zoom, review the requested read permissions, and authorize the app. Your Zoom administrator may need to approve it first.
+3. Return to Sim and confirm your account appears on the Zoom row. Current Zoom permissions determine which meetings and artifacts you can read.
+
+If connection fails, see [Troubleshooting](#troubleshooting).
+
+Sim uses the official [Zoom Meetings MCP server](https://developers.zoom.us/docs/mcp/zoom-meetings-mcp-server/). Search uses a separate General OAuth app registration from workflow actions. Zoom reauthorization can replace or narrow an existing user/app grant, so sharing the workflow client would risk disconnecting existing workflows. A Zoom workflow connection does not authorize Search.
+
+For self-hosted deployments, configure `ZOOM_MCP_CLIENT_ID` and `ZOOM_MCP_CLIENT_SECRET` from a separate General, User-managed Search app. In the [Zoom app](https://developers.zoom.us/docs/mcp/servers/connect-to-zoom-mcp-servers/), enable `meeting:read:search` and `meeting:read:assets` and register the exact callback below in both the redirect field and OAuth allow list:
+
+```text
+https:///api/mcp/oauth/callback
+```
+
+An internal app works only for users in its Zoom account. Connecting users from other Zoom accounts requires [approved external distribution](https://developers.zoom.us/docs/build-flow/before-you-build/), including for unlisted production apps. Limited external beta testing uses Zoom's separate [sharing approval](https://developers.zoom.us/docs/distribute/sharing-private-and-beta-apps/).
+
+The OAuth exchange uses PKCE and `client_secret_basic`. Search requests only those two read scopes, even when Zoom discovery advertises write tools. The fixed endpoint is `https://mcp.zoom.us/mcp/meeting/streamable`; Sim allows only `search_meetings` and `get_meeting_assets`.
+
+## Search and read
+
+On **Home**, ask a question such as “What did we decide about deployment rollback last week? Search Zoom and cite the transcript.” Follow a result’s source link to open it in Zoom.
+
+Use short plain keywords such as `deployment rollback`. Zoom matches meeting topics, agendas and available meeting content. Results identify past meeting occurrences by UUID, rather than the recurring meeting number. Use `kind: meeting` when sending multiple native queries to one account.
+
+`startDate` and `endDate` filter actual meeting start time. The end is exclusive. Continue with `nextCursor` using the same account, query and filters; Zoom's page token expires after 15 minutes. Each page verifies at most 10 candidates. Sorting the returned candidates does not establish the globally newest or oldest match.
+
+Read a result for available timestamped transcripts, personal notes and separately labeled AI-generated summaries. Generated summaries and notes are not verbatim speech. Sim does not download recordings or transcribe audio. Recording, transcription and AI Companion settings, licenses, processing state and sharing permissions determine which artifacts exist.
+
+Boolean/field operators, project selection, ownership filters and modification-date filters are unsupported. An absent artifact does not prove a meeting had no discussion. Oversized or malformed reads fail explicitly; provider failures do not appear as a successful search with no matches.
+
+## Disconnect
+
+In Sim **Integrations**, open the **…** menu beside Zoom, choose **Disconnect** for your account, and confirm. This stops that connection from being used in this organization. Any workflows using the same connection also lose access; other people's connections are unaffected.
+
+To remove the authorization from Zoom too, open **Zoom App Marketplace → My Library**, find the Sim app you authorized for Search, open its **More** menu, and choose **Remove**, then confirm. See [Zoom's removal instructions](https://support.zoom.com/hc/en/article?id=zm_kb&sysparm_article=KB0062865); your Zoom administrator may control app removal.
+
+Disconnecting does not delete meetings or recordings in Zoom or erase existing Sim conversations. See [Sim's Privacy Policy](https://www.sim.ai/privacy) for data handling and deletion requests.
+
+## Troubleshooting
+
+- **Connect is unavailable:** ask your Sim administrator to enable the source and finish the Zoom app configuration. Ask your Zoom administrator about app approval if authorization is blocked.
+- **Reconnect needed:** use **Reconnect** on the Zoom row in Integrations and authorize the same account again.
+- **Missing meeting or text:** confirm you can open it in Zoom, try a distinctive topic and date range, and check whether recording, transcription or AI summary processing has finished. Available content depends on the meeting's settings and your permissions.
+- **Expired cursor or changed content:** run the search again before continuing the result. Zoom page tokens expire after 15 minutes.
+
+For help, contact [help@sim.ai](mailto:help@sim.ai).
diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json
index 9dfb8b96b8e..c374293200e 100644
--- a/apps/docs/openapi-v2-files-audit.json
+++ b/apps/docs/openapi-v2-files-audit.json
@@ -248,7 +248,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/V2FileResponse"
+ "$ref": "#/components/schemas/V2CreatedFileResponse"
}
}
}
@@ -4044,18 +4044,112 @@
}
]
},
- "V2FileResponse": {
+ "V2CreatedFile": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique file identifier.",
+ "examples": ["wf_V1StGXR8z5jdHi6BmyT91"]
+ },
+ "webUrl": {
+ "type": "string",
+ "format": "uri",
+ "description": "Canonical absolute URL for opening this resource in the Sim web application."
+ },
+ "name": {
+ "type": "string",
+ "description": "Original file name.",
+ "examples": ["data.csv"]
+ },
+ "size": {
+ "type": "number",
+ "minimum": 0,
+ "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.",
+ "examples": [1024]
+ },
+ "type": {
+ "type": "string",
+ "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.",
+ "examples": ["text/csv"]
+ },
+ "key": {
+ "type": "string",
+ "description": "Storage key for the file.",
+ "examples": ["workspace/example/data.csv"]
+ },
+ "folderPath": {
+ "type": "string",
+ "title": "Folder path",
+ "description": "Canonical containing-folder path. `/` is the workspace root.",
+ "maxLength": 4096
+ },
+ "uploadedByEmail": {
+ "type": "string",
+ "format": "email",
+ "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
+ "description": "Current email address of the uploader.",
+ "examples": ["jane@example.com"]
+ },
+ "uploadedAt": {
+ "type": "string",
+ "description": "ISO 8601 timestamp when the file was uploaded.",
+ "format": "date-time",
+ "examples": ["2026-01-15T10:30:00Z"]
+ },
+ "updatedAt": {
+ "type": "string",
+ "description": "ISO 8601 timestamp of the last content or metadata write.",
+ "format": "date-time",
+ "examples": ["2026-01-15T10:30:00Z"]
+ },
+ "deletedAt": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.",
+ "format": "date-time",
+ "examples": ["2026-01-16T09:00:00Z"]
+ },
+ "revision": {
+ "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "webUrl",
+ "name",
+ "size",
+ "type",
+ "key",
+ "folderPath",
+ "uploadedByEmail",
+ "uploadedAt",
+ "updatedAt",
+ "deletedAt"
+ ],
+ "additionalProperties": false,
+ "title": "Created file",
+ "description": "A newly created workspace file, with the revision it produced."
+ },
+ "V2CreatedFileResponse": {
"type": "object",
"properties": {
"data": {
"description": "Response data.",
- "$ref": "#/components/schemas/V2File"
+ "$ref": "#/components/schemas/V2CreatedFile"
}
},
"required": ["data"],
"additionalProperties": false,
- "title": "File response",
- "description": "A single workspace file.",
+ "title": "Created file response",
+ "description": "A newly created workspace file, with the revision it produced.",
"examples": [
{
"data": {
@@ -5115,6 +5209,36 @@
"title": "Delete file response",
"description": "Deletion confirmation for one file."
},
+ "V2FileResponse": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "Response data.",
+ "$ref": "#/components/schemas/V2File"
+ }
+ },
+ "required": ["data"],
+ "additionalProperties": false,
+ "title": "File response",
+ "description": "A single workspace file.",
+ "examples": [
+ {
+ "data": {
+ "id": "wf_V1StGXR8z5jdHi6BmyT91",
+ "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91",
+ "name": "data.csv",
+ "size": 1024,
+ "type": "text/csv",
+ "key": "workspace/example/data.csv",
+ "folderPath": "/Engineering",
+ "uploadedByEmail": "jane@example.com",
+ "uploadedAt": "2026-01-15T10:30:00Z",
+ "updatedAt": "2026-01-15T10:30:00Z",
+ "deletedAt": null
+ }
+ }
+ ]
+ },
"RenameFileRequest": {
"type": "object",
"properties": {
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 983c7c3c209..d72d0f1a8cc 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -266,3 +266,9 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback in the HubSpot MCP connector.
# HUBSPOT_MCP_CLIENT_ID=
# HUBSPOT_MCP_CLIENT_SECRET=
+
+# Zoom member search: separate General OAuth app to preserve workflow grants.
+# Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback with the two meeting read scopes.
+# ZOOM_SEARCH=false # Off-AppConfig fallback; set true to enable for eligible organization-owned scopes
+# ZOOM_MCP_CLIENT_ID=
+# ZOOM_MCP_CLIENT_SECRET=
diff --git a/apps/sim/app/(auth)/components/auth-button-classes.ts b/apps/sim/app/(auth)/components/auth-button-classes.ts
deleted file mode 100644
index 5e029674aaf..00000000000
--- a/apps/sim/app/(auth)/components/auth-button-classes.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-/** Shared className for inline auth action links. */
-export const AUTH_TEXT_LINK =
- 'text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const
diff --git a/apps/sim/app/(auth)/components/auth-text-link.tsx b/apps/sim/app/(auth)/components/auth-text-link.tsx
index 71a7d0f995d..ea464c71269 100644
--- a/apps/sim/app/(auth)/components/auth-text-link.tsx
+++ b/apps/sim/app/(auth)/components/auth-text-link.tsx
@@ -21,8 +21,7 @@ interface AuthTextLinkProps {
/**
* The canonical inline text affordance for the auth pages — forgot-password,
* resend, and the legal links. Renders a {@link Link} when `href` is set and a
- * `` otherwise, both in one light-token style. Replaces the legacy dark
- * `AUTH_TEXT_LINK` class string with a single props-driven source of truth.
+ * `` otherwise, both using the shared neutral text tokens.
*/
export function AuthTextLink({
children,
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index fcdbc0f13b8..aa3d121a4c3 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -5,6 +5,7 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
+import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { client, useSession } from '@/lib/auth/auth-client'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
@@ -109,7 +110,7 @@ function SignupFormContent({
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const posthog = usePostHog()
- const { measurement } = useTrackingConsent()
+ const { measurement, marketing } = useTrackingConsent()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
@@ -348,6 +349,7 @@ function SignupFormContent({
}
if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
+ if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id)
try {
await refetchSession()
diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
index 8710f405311..685f833aa33 100644
--- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
+++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
@@ -5,8 +5,7 @@ import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/em
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
-import { AuthSubmitButton } from '@/app/(auth)/components'
-import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
+import { AuthSubmitButton, AuthTextLink } from '@/app/(auth)/components'
import { useChatEmailOtpRequest, useChatEmailOtpVerify } from '@/hooks/queries/chats'
const logger = createLogger('EmailAuth')
@@ -220,28 +219,26 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
Resend in {countdown}s
) : (
-
Resend
-
+
)}
- {
setShowOtpVerification(false)
setOtpValue('')
setAuthError(null)
}}
- className={AUTH_TEXT_LINK}
>
Change email
-
+
)}
diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx
index a30745054b2..b3ef3fd9e54 100644
--- a/apps/sim/app/_shell/consent/consent-provider.tsx
+++ b/apps/sim/app/_shell/consent/consent-provider.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { TrackingConsentProvider } from '@/lib/consent/tracking-consent'
import { ConsentBanner } from '@/app/_shell/consent/consent-banner'
import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
+import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard'
import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker'
interface ConsentProviderProps {
@@ -22,6 +23,7 @@ export function ConsentProvider({ children }: ConsentProviderProps) {
{children}
+
diff --git a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx
new file mode 100644
index 00000000000..bc221be8fb0
--- /dev/null
+++ b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { useEffect } from 'react'
+import { clearFreebuffClickId } from '@/lib/analytics/freebuff'
+import { useTrackingConsent } from '@/lib/consent/tracking-consent'
+
+/**
+ * Drops a stored Freebuff click id once consent resolves without marketing, so
+ * a withdrawn or expired grant can never be attributed by the server postback,
+ * which only sees the cookie. Withdrawal reloads the page, so this runs before
+ * any later signup.
+ */
+export function FreebuffClickIdGuard() {
+ const { isResolved, marketing } = useTrackingConsent()
+
+ useEffect(() => {
+ if (isResolved && !marketing) clearFreebuffClickId()
+ }, [isResolved, marketing])
+
+ return null
+}
diff --git a/apps/sim/app/api/billing/update-cost/route.integration.ts b/apps/sim/app/api/billing/update-cost/route.integration.ts
new file mode 100644
index 00000000000..6263a693d6a
--- /dev/null
+++ b/apps/sim/app/api/billing/update-cost/route.integration.ts
@@ -0,0 +1,159 @@
+/**
+ * Cost callbacks against real PostgreSQL: a direct-v1 run that outlives its admitted Stripe period
+ * records its later spend in the payer's current period, so the closed period is never topped up
+ * after its invoice, and spend after the payer's terminal settlement is refused. Only the
+ * internal-key check is stubbed.
+ */
+import { db } from '@sim/db'
+import { subscription, usageLog, user, userStats } from '@sim/db/schema'
+import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
+import { generateId } from '@sim/utils/id'
+import { eq, inArray } from 'drizzle-orm'
+import { NextRequest } from 'next/server'
+import { afterAll, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ ...envFlagsMock,
+ isHosted: true,
+ isBillingEnabled: true,
+}))
+vi.mock('@/lib/mothership/request/http', async (importOriginal) => ({
+ ...(await importOriginal()),
+ checkInternalApiKey: () => ({ success: true }),
+}))
+
+import {
+ BILLING_ACCOUNT_DECISION_HEADER,
+ serializeAccountBillingDecisionHeader,
+} from '@/lib/billing/core/billing-attribution'
+import { claimTerminalPeriod } from '@/lib/billing/cycle-close'
+import { POST } from '@/app/api/billing/update-cost/route'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const userIds: string[] = []
+
+afterAll(async () => {
+ if (userIds.length === 0) return
+ await db.delete(usageLog).where(inArray(usageLog.userId, userIds))
+ await db.delete(subscription).where(inArray(subscription.referenceId, userIds))
+ await db.delete(userStats).where(inArray(userStats.userId, userIds))
+ await db.delete(user).where(inArray(user.id, userIds))
+})
+
+interface Payer {
+ userId: string
+ subscriptionId: string
+ /** The direct-v1 decision of a run admitted in the subscription's period. */
+ decision: string
+}
+
+/** A user on a pro subscription for `period`, whose close marker has caught up to it. */
+async function createPayer(period: { start: Date; end: Date }): Promise {
+ const userId = `update-cost-user-${generateId()}`
+ const subscriptionId = generateId()
+ userIds.push(userId)
+ await db.insert(user).values({
+ id: userId,
+ name: 'Update Cost Test',
+ email: `${userId}@update-cost.test`,
+ emailVerified: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ await db.insert(userStats).values({ id: generateId(), userId })
+ await db.insert(subscription).values({
+ id: subscriptionId,
+ plan: 'pro',
+ referenceId: userId,
+ status: 'active',
+ periodStart: period.start,
+ periodEnd: period.end,
+ lastClosedPeriodStart: period.start,
+ })
+ const decision = serializeAccountBillingDecisionHeader({
+ userId,
+ billingEntity: { type: 'user', id: userId },
+ billingPeriod: {
+ start: period.start.toISOString(),
+ end: period.end.toISOString(),
+ source: 'stripe',
+ },
+ payerSubscriptionId: subscriptionId,
+ })
+ return { userId, subscriptionId, decision }
+}
+
+function requestRows(requestKey: string) {
+ return db
+ .select({
+ eventKey: usageLog.eventKey,
+ cost: usageLog.cost,
+ billingPeriodStart: usageLog.billingPeriodStart,
+ })
+ .from(usageLog)
+ .where(inArray(usageLog.eventKey, [`update-cost:${requestKey}`, `update-cost:${requestKey}@1`]))
+}
+
+function callback(payer: Payer, requestKey: string, cost: number): NextRequest {
+ return new NextRequest('http://localhost:3000/api/billing/update-cost', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': requestKey,
+ [BILLING_ACCOUNT_DECISION_HEADER]: payer.decision,
+ },
+ body: JSON.stringify({
+ userId: payer.userId,
+ cost,
+ model: 'test-model',
+ source: 'copilot',
+ idempotencyKey: requestKey,
+ }),
+ })
+}
+
+describe('direct-v1 cost callbacks in PostgreSQL', () => {
+ it('records spend after a Stripe rollover in the payer current period', async () => {
+ const now = Date.now()
+ const admitted = { start: new Date(now - 10 * DAY_MS), end: new Date(now + 20 * DAY_MS) }
+ const rolled = { start: new Date(now - 60 * 60 * 1000), end: new Date(now + 30 * DAY_MS) }
+ const payer = await createPayer(admitted)
+ const requestKey = generateId()
+
+ expect((await POST(callback(payer, requestKey, 0.5), {})).status).toBe(200)
+ await db
+ .update(subscription)
+ .set({ periodStart: rolled.start, periodEnd: rolled.end })
+ .where(eq(subscription.id, payer.subscriptionId))
+ expect((await POST(callback(payer, requestKey, 0.8), {})).status).toBe(200)
+
+ const rows = await requestRows(requestKey)
+ const byKey = new Map(rows.map((row) => [row.eventKey, row]))
+ expect(rows).toHaveLength(2)
+ expect(Number(byKey.get(`update-cost:${requestKey}`)?.cost)).toBeCloseTo(0.5)
+ expect(byKey.get(`update-cost:${requestKey}`)?.billingPeriodStart?.getTime()).toBe(
+ admitted.start.getTime()
+ )
+ expect(Number(byKey.get(`update-cost:${requestKey}@1`)?.cost)).toBeCloseTo(0.3)
+ expect(byKey.get(`update-cost:${requestKey}@1`)?.billingPeriodStart?.getTime()).toBe(
+ rolled.start.getTime()
+ )
+ })
+
+ it("refuses spend that lands after the payer's terminal settlement", async () => {
+ const now = Date.now()
+ const period = { start: new Date(now - 10 * DAY_MS), end: new Date(now + 20 * DAY_MS) }
+ const payer = await createPayer(period)
+ const requestKey = generateId()
+
+ expect((await POST(callback(payer, requestKey, 0.5), {})).status).toBe(200)
+ await claimTerminalPeriod(payer.subscriptionId)
+ const late = await POST(callback(payer, requestKey, 0.8), {})
+
+ expect(late.status).toBe(409)
+ expect(await late.json()).toMatchObject({ code: 'BILLING_PERIOD_ELAPSED', retryable: false })
+ expect((await requestRows(requestKey)).map((row) => Number(row.cost))).toEqual([0.5])
+ })
+})
diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts
index dd89be51224..15893913840 100644
--- a/apps/sim/app/api/billing/update-cost/route.test.ts
+++ b/apps/sim/app/api/billing/update-cost/route.test.ts
@@ -4,12 +4,19 @@ import {
billingAttributionMock,
billingAttributionMockFns,
} from '@sim/testing/mocks/billing-attribution.mock'
+import { billingCoreMock, billingCoreMockFns } from '@sim/testing/mocks/billing-core.mock'
+import { billingPlanMock, billingPlanMockFns } from '@sim/testing/mocks/billing-plan.mock'
import {
billingUsageLogMock,
billingUsageLogMockFns,
} from '@sim/testing/mocks/billing-usage-log.mock'
+import {
+ billingUsageMonitorMock,
+ billingUsageMonitorMockFns,
+} from '@sim/testing/mocks/billing-usage-monitor.mock'
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing/mocks/copilot-http.mock'
import { mothershipOtelMock } from '@sim/testing/mocks/mothership-otel.mock'
+import { sleep } from '@sim/utils/helpers'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -41,12 +48,20 @@ vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock)
vi.mock('@/lib/billing/core/billing-attribution', () => billingAttributionMock)
+vi.mock('@/lib/billing/core/billing', () => billingCoreMock)
+
+vi.mock('@/lib/billing/core/plan', () => billingPlanMock)
+
+vi.mock('@/lib/billing/calculations/usage-monitor', () => billingUsageMonitorMock)
+
vi.mock('@/lib/billing/threshold-billing', () => ({
checkAndBillOverageThreshold: mockCheckAndBillOverageThreshold,
checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
ThresholdSettlementError: MockThresholdSettlementError,
}))
+import { resetMidRunUsageCaches } from '@/lib/billing/core/mid-run-usage'
+import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache'
import {
BillingCallbackBody,
BillingCallbackHeaders,
@@ -69,6 +84,8 @@ const mockRequireBillingAttributionHeader =
const mockResolveLegacyV0BillingAttribution =
billingAttributionMockFns.mockResolveLegacyV0BillingAttribution
const mockToBillingContext = billingAttributionMockFns.mockToBillingContext
+const mockCheckAttributedUsageLimits = billingAttributionMockFns.mockCheckAttributedUsageLimits
+const mockRefreshAttributionPeriod = billingAttributionMockFns.mockRefreshAttributionPeriod
afterAll(resetEnvFlagsMock)
@@ -236,6 +253,18 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})
+ it('rejects an idempotency key that could collide with a period row key', async () => {
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ { ...SELF_HOSTED_UPDATE_COST_BODY, idempotencyKey: 'old-go-key@1' },
+ { 'x-api-key': 'internal' }
+ )
+ )
+
+ expect(res.status).toBe(400)
+ })
+
it('rejects billing-enabled callbacks without a stable idempotency key', async () => {
const res = await POST(
createMockRequest('POST', KEYLESS_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
@@ -847,3 +876,453 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})
})
+
+describe('POST /api/billing/update-cost — mid-run usage gate', () => {
+ let callbackSequence = 0
+ /** A Stripe-period payer admitted in a period that has since ended. */
+ const STRIPE_ATTRIBUTION = {
+ ...ATTRIBUTION,
+ billingPeriod: { ...ATTRIBUTION.billingPeriod, source: 'stripe' as const },
+ }
+ const CURRENT_ATTRIBUTION = {
+ ...ATTRIBUTION,
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2099-01-01T00:00:00.000Z',
+ source: 'stripe' as const,
+ },
+ }
+
+ function attributedCallback() {
+ callbackSequence += 1
+ const billingRequestId = `0190c03f-9f7d-4b79-8b58-${String(callbackSequence).padStart(12, '0')}`
+ return createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5 * callbackSequence,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ 'x-sim-billing-attribution': 'serialized-attribution',
+ }
+ )
+ }
+
+ beforeEach(() => {
+ resetUsageGateCache()
+ resetMidRunUsageCaches()
+ setEnvFlags({ isBillingEnabled: true, isHosted: true })
+ mockCheckInternalApiKey.mockReturnValue({ success: true })
+ mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
+ mockCheckAndBillPayerOverageThreshold.mockResolvedValue(undefined)
+ mockRequireBillingAttributionHeader.mockReturnValue(CURRENT_ATTRIBUTION)
+ mockRefreshAttributionPeriod.mockImplementation(async (attribution: unknown) => attribution)
+ mockToBillingContext.mockReturnValue({
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })
+ })
+
+ it('tells the worker when the run payer has crossed its usage limit', async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ const res = await POST(attributedCallback())
+
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toMatchObject({
+ success: true,
+ usageExceeded: true,
+ usageUpgrade: {
+ reason: 'usage_limit',
+ action: 'upgrade_plan',
+ message: expect.stringContaining('usage limit'),
+ },
+ })
+ })
+
+ it('offers a paid organization payer the increase-limit card', async () => {
+ mockRequireBillingAttributionHeader.mockReturnValue({
+ ...CURRENT_ATTRIBUTION,
+ payerSubscription: { id: 'sub-1', plan: 'team', status: 'active', seats: 4 },
+ })
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ const res = await POST(attributedCallback())
+
+ const body = await res.json()
+ expect(body.usageUpgrade).toMatchObject({
+ action: 'increase_limit',
+ message: expect.stringContaining('organization'),
+ })
+ })
+
+ it('serves a cached admission to every step and re-reads a refusal', async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+
+ expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(false)
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+ for (let step = 0; step < 4; step++) {
+ const body = await (await POST(attributedCallback())).json()
+ expect(body.usageExceeded).toBe(false)
+ expect(body).not.toHaveProperty('usageUpgrade')
+ }
+
+ resetUsageGateCache()
+ expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(true)
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+ expect((await (await POST(attributedCallback())).json()).usageExceeded).toBe(false)
+ })
+
+ it('answers a duplicate retry with the verdict its lost first answer carried', async () => {
+ mockRecordCumulativeUsage.mockResolvedValue({ billed: false, delta: 0, total: 0.5 })
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ const res = await POST(attributedCallback())
+
+ expect(res.status).toBe(409)
+ await expect(res.json()).resolves.toMatchObject({
+ code: 'DUPLICATE_BILLING_EVENT',
+ usageExceeded: true,
+ usageUpgrade: { action: 'upgrade_plan' },
+ })
+ })
+
+ describe('a direct-v1 run', () => {
+ function directCallback() {
+ callbackSequence += 1
+ const billingRequestId = `0190c03f-9f7d-4b79-8b58-${String(callbackSequence).padStart(12, '0')}`
+ return createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5 * callbackSequence,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ 'x-sim-billing-account-decision': 'serialized-account-decision',
+ }
+ )
+ }
+
+ beforeEach(() => {
+ mockRequireAccountBillingDecisionHeader.mockReturnValue(ACCOUNT_BILLING_DECISION)
+ billingCoreMockFns.mockGetOrganizationSubscription.mockResolvedValue(null)
+ billingPlanMockFns.mockGetHighestPrioritySubscription.mockResolvedValue(null)
+ billingAttributionMockFns.mockCheckAccountBillingBlocks.mockResolvedValue({ blocked: false })
+ billingUsageMonitorMockFns.mockCheckUsageStatus.mockResolvedValue({
+ isExceeded: true,
+ currentUsage: 12,
+ limit: 10,
+ })
+ })
+
+ it('tells the worker when its admitted payer has crossed its usage limit', async () => {
+ const body = await (await POST(directCallback())).json()
+
+ expect(body).toMatchObject({
+ usageExceeded: true,
+ usageUpgrade: { reason: 'usage_limit' },
+ })
+ })
+
+ it("offers the card for its admitted payer's plan, not the actor's current one", async () => {
+ billingCoreMockFns.mockGetOrganizationSubscription.mockResolvedValue({
+ id: 'sub-account-org',
+ referenceId: 'account-org',
+ plan: 'team',
+ status: 'active',
+ seats: 4,
+ })
+ billingPlanMockFns.mockGetHighestPrioritySubscription.mockResolvedValue({
+ id: 'sub-personal',
+ referenceId: 'user-1',
+ plan: 'pro',
+ status: 'active',
+ })
+
+ const body = await (await POST(directCallback())).json()
+
+ expect(body.usageUpgrade).toMatchObject({
+ action: 'increase_limit',
+ message: expect.stringContaining("organization's usage limit"),
+ })
+ })
+
+ it('never pauses a blocked payer with the usage card', async () => {
+ billingAttributionMockFns.mockCheckAccountBillingBlocks.mockResolvedValue({
+ blocked: true,
+ scope: 'payer',
+ })
+
+ const body = await (await POST(directCallback())).json()
+
+ expect(body.usageExceeded).toBe(false)
+ })
+ })
+
+ describe('a run that outlives its billing period', () => {
+ const PAYER_SUBSCRIPTION = {
+ id: 'sub-1',
+ plan: 'team',
+ status: 'active',
+ seats: 4,
+ }
+ const ADMITTED_PERIOD = {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ }
+ const CURRENT_PERIOD = {
+ start: new Date('2026-08-01T00:00:00.000Z'),
+ end: new Date('2026-09-01T00:00:00.000Z'),
+ }
+
+ function admittedWithSource(source: 'stripe' | 'reporting' | 'default') {
+ mockToBillingContext.mockReturnValue({
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: { ...ADMITTED_PERIOD, source },
+ })
+ }
+
+ /** Threshold settlement for a payer whose charges belong to `period` refuses any other. */
+ function settlesOnlyAgainst(period: typeof ADMITTED_PERIOD) {
+ mockCheckAndBillPayerOverageThreshold.mockImplementation(
+ async (_payer: unknown, options: { expectedBillingPeriod: typeof ADMITTED_PERIOD }) => {
+ if (options.expectedBillingPeriod.start.getTime() !== period.start.getTime()) {
+ throw new Error('Settled against a period the charge did not land in')
+ }
+ }
+ )
+ }
+
+ beforeEach(() => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockRequireBillingAttributionHeader.mockReturnValue({
+ ...CURRENT_ATTRIBUTION,
+ payerSubscription: PAYER_SUBSCRIPTION,
+ })
+ // As the ledger behaves: a charge given the payer's subscription lands in its current
+ // period, any other stays in the period it was admitted in.
+ mockRecordCumulativeUsage.mockImplementation(
+ async (params: {
+ payerSubscriptionId?: string
+ billingPeriod: typeof ADMITTED_PERIOD
+ }) => ({
+ billed: true,
+ delta: 0.5,
+ total: 1.5,
+ billingPeriod: params.payerSubscriptionId
+ ? CURRENT_PERIOD
+ : { start: params.billingPeriod.start, end: params.billingPeriod.end },
+ })
+ )
+ })
+
+ it("records a Stripe payer's charge in its current period and settles it there", async () => {
+ admittedWithSource('stripe')
+ settlesOnlyAgainst(CURRENT_PERIOD)
+
+ expect((await POST(attributedCallback())).status).toBe(200)
+ })
+
+ it('leaves a period that closed under a recorded charge to the cycle close', async () => {
+ admittedWithSource('stripe')
+ mockRecordCumulativeUsage.mockResolvedValue({
+ billed: true,
+ delta: 0.5,
+ total: 1.5,
+ billingPeriod: ADMITTED_PERIOD,
+ })
+ mockCheckAndBillPayerOverageThreshold.mockRejectedValue(
+ new MockThresholdSettlementError('billing_period_elapsed')
+ )
+
+ const res = await POST(attributedCallback())
+
+ expect(res.status).toBe(200)
+ })
+
+ it.each(['reporting', 'default'] as const)(
+ 'keeps a payer with a %s period on the period it was admitted in',
+ async (source) => {
+ admittedWithSource(source)
+ settlesOnlyAgainst(ADMITTED_PERIOD)
+
+ expect((await POST(attributedCallback())).status).toBe(200)
+ }
+ )
+ })
+
+ it.each([
+ ['an unreadable ledger', { isExceeded: true, reason: 'usage_unavailable' }],
+ ['a blocked account', { isExceeded: true, reason: 'billing_blocked', scope: 'payer' }],
+ ])('does not pause a run for %s', async (_case, verdict) => {
+ mockCheckAttributedUsageLimits.mockResolvedValue(verdict)
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(false)
+ expect(body).not.toHaveProperty('usageUpgrade')
+ })
+
+ it('tells a member over the cap their organization set who can raise it', async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'member' })
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body).toMatchObject({
+ usageUpgrade: { message: expect.stringMatching(/limit your organization set for you/) },
+ })
+ })
+
+ /** The gate refuses only when it judges the payer's current period. */
+ function refuseOnlyCurrentPeriod() {
+ mockCheckAttributedUsageLimits.mockImplementation(
+ async (attribution: typeof CURRENT_ATTRIBUTION) => ({
+ isExceeded: attribution.billingPeriod.end === CURRENT_ATTRIBUTION.billingPeriod.end,
+ scope: 'payer',
+ })
+ )
+ }
+
+ it('judges a run past its admitted period against the payer current period', async () => {
+ mockRequireBillingAttributionHeader.mockReturnValue(STRIPE_ATTRIBUTION)
+ mockRefreshAttributionPeriod.mockResolvedValue(CURRENT_ATTRIBUTION)
+ refuseOnlyCurrentPeriod()
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(true)
+ })
+
+ it('judges a run whose payer period moved early against the moved period', async () => {
+ const moved = {
+ ...CURRENT_ATTRIBUTION,
+ billingPeriod: { start: '2026-07-15T00:00:00.000Z', end: '2099-02-01T00:00:00.000Z' },
+ }
+ mockRefreshAttributionPeriod.mockResolvedValue(moved)
+ mockCheckAttributedUsageLimits.mockImplementation(async (attribution: typeof moved) => ({
+ isExceeded: attribution.billingPeriod.start === moved.billingPeriod.start,
+ scope: 'payer',
+ }))
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(true)
+ })
+
+ it('judges a reporting-window run against its admitted window after that window ends', async () => {
+ const admitted = {
+ ...ATTRIBUTION,
+ billingPeriod: { ...ATTRIBUTION.billingPeriod, source: 'reporting' as const },
+ }
+ mockRequireBillingAttributionHeader.mockReturnValue(admitted)
+ mockRefreshAttributionPeriod.mockResolvedValue({
+ ...CURRENT_ATTRIBUTION,
+ billingPeriod: { ...CURRENT_ATTRIBUTION.billingPeriod, source: 'reporting' as const },
+ })
+ mockCheckAttributedUsageLimits.mockImplementation(async (attribution: typeof admitted) => ({
+ isExceeded: attribution.billingPeriod.end === admitted.billingPeriod.end,
+ scope: 'payer',
+ }))
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(true)
+ })
+
+ it('keeps a run going when its current period cannot be read', async () => {
+ mockRequireBillingAttributionHeader.mockReturnValue(STRIPE_ATTRIBUTION)
+ mockRefreshAttributionPeriod.mockRejectedValue(new Error('subscription read timed out'))
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(false)
+ })
+
+ it('answers not exceeded when the standing read outlasts the callback budget', async () => {
+ mockCheckAttributedUsageLimits.mockImplementation(async () => {
+ await sleep(1500)
+ return { isExceeded: true, scope: 'payer' }
+ })
+ const startedAt = Date.now()
+
+ const res = await POST(attributedCallback())
+
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toMatchObject({ success: true, usageExceeded: false })
+ expect(Date.now() - startedAt).toBeLessThan(1400)
+ })
+
+ it('does not pause a run on a verdict read across the end of its period', async () => {
+ const straddling = {
+ ...CURRENT_ATTRIBUTION,
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: new Date(Date.now() + 40).toISOString(),
+ source: 'stripe' as const,
+ },
+ }
+ mockRefreshAttributionPeriod.mockResolvedValue(straddling)
+ mockCheckAttributedUsageLimits.mockImplementation(async () => {
+ await sleep(80)
+ return { isExceeded: true, scope: 'payer' }
+ })
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(false)
+ })
+
+ it('reloads a cached current period once it has ended', async () => {
+ const ending = {
+ ...CURRENT_ATTRIBUTION,
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: new Date(Date.now() + 50).toISOString(),
+ },
+ }
+ mockRefreshAttributionPeriod
+ .mockResolvedValueOnce(ending)
+ .mockResolvedValue(CURRENT_ATTRIBUTION)
+ refuseOnlyCurrentPeriod()
+ await POST(attributedCallback())
+ await sleep(100)
+
+ const body = await (await POST(attributedCallback())).json()
+
+ expect(body.usageExceeded).toBe(true)
+ })
+
+ it('keeps a recorded charge successful when the gate read fails', async () => {
+ mockCheckAttributedUsageLimits.mockRejectedValue(new Error('ledger read timed out'))
+
+ const res = await POST(attributedCallback())
+
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toMatchObject({ success: true, usageExceeded: false })
+ })
+
+ it('reports no exceeded usage when billing is disabled', async () => {
+ setEnvFlags({ isBillingEnabled: false, isHosted: true })
+
+ const res = await POST(attributedCallback())
+
+ await expect(res.json()).resolves.toMatchObject({ usageExceeded: false })
+ })
+})
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
index 0f789f87c99..0c66f9fc346 100644
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -2,7 +2,11 @@ import type { Span } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
-import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
+import {
+ type BillingUpdateCostResponse,
+ type BillingUsageVerdict,
+ billingUpdateCostContract,
+} from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import {
type AccountBillingDecision,
@@ -18,9 +22,15 @@ import {
resolveLegacyV0BillingAttribution,
toBillingContext,
} from '@/lib/billing/core/billing-attribution'
+import {
+ type MidRunUsageVerdict,
+ readMidRunAccountUsageVerdict,
+ readMidRunUsageVerdict,
+} from '@/lib/billing/core/mid-run-usage'
import {
type CumulativeUsageContextField,
CumulativeUsageContextMismatchError,
+ CumulativeUsagePeriodClosedError,
recordCumulativeUsage,
} from '@/lib/billing/core/usage-log'
import {
@@ -28,7 +38,9 @@ import {
checkAndBillPayerOverageThreshold,
ThresholdSettlementError,
} from '@/lib/billing/threshold-billing'
+import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
+import { withinDeadline } from '@/lib/core/utils/deadline'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { BILLING_CALLBACK_OUTCOME } from '@/lib/mothership/generated/billing-protocol-v1'
@@ -39,6 +51,14 @@ import { checkInternalApiKey } from '@/lib/mothership/request/http'
import { withIncomingGoSpan } from '@/lib/mothership/request/otel'
const logger = createLogger('BillingUpdateCostAPI')
+/**
+ * How long a cost callback waits on the payer's standing. The worker gives up on the whole
+ * callback after 5 s, and a cold gate read can wait on the ledger far longer; past this the
+ * callback answers not-exceeded. The abandoned read keeps running and caches its admission, and
+ * the next step or re-check reads a refusal again.
+ */
+const USAGE_STANDING_TIMEOUT_MS = 1000
+
const RETRYABLE_SETTLEMENT_RESPONSE = {
code: 'BILLING_SETTLEMENT_RETRYABLE',
error: 'Billing settlement temporarily unavailable',
@@ -57,6 +77,49 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp
)
}
+/**
+ * Reads the run payer's standing after a cost callback, so a long run stops at its next step
+ * once it crosses the limit instead of at its next admission, with the card the worker writes
+ * to its log. The payer is the attributed run's, or the one a direct-v1 run was admitted with.
+ * A duplicate callback answers too: it is often a retry whose first answer was lost. An
+ * admission is cached per payer and actor for the gate TTL and a refusal is always re-read, so
+ * steady-state steps cost no ledger read. The charge is
+ * already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the
+ * refusal to the next step or re-check rather than ending a paying run on a database blip,
+ * and so does a verdict read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. An exceeded
+ * verdict always pauses the run.
+ */
+async function readUsageStanding(
+ userId: string,
+ billingAttribution: BillingAttributionSnapshot | undefined,
+ accountDecision: AccountBillingDecision | undefined
+): Promise {
+ const readVerdict = billingAttribution
+ ? () => readMidRunUsageVerdict(billingAttribution)
+ : accountDecision
+ ? () => readMidRunAccountUsageVerdict(accountDecision)
+ : null
+ if (!isHosted || !readVerdict) return { usageExceeded: false }
+ let verdict: MidRunUsageVerdict
+ try {
+ verdict = await withinDeadline(readVerdict, Date.now() + USAGE_STANDING_TIMEOUT_MS)
+ } catch {
+ logger.warn('Usage standing read outlasted the callback budget; answering not exceeded')
+ return { usageExceeded: false }
+ }
+ // Only a spent limit pauses the run. A blocked account is refused at the run's next
+ // continuation or re-check, with blocked-account copy rather than the upgrade card.
+ if (verdict.status !== 'exceeded') return { usageExceeded: false }
+ return {
+ usageExceeded: true,
+ usageUpgrade: await resolveUsageUpgradePayload(
+ userId,
+ billingAttribution ?? verdict.payer,
+ verdict.scope
+ ),
+ }
+}
+
function getBillingResolution(
isMarkerlessLegacy: boolean,
billingAttribution: BillingAttributionSnapshot | undefined
@@ -112,9 +175,10 @@ async function updateCostInner(req: NextRequest, span: Span): Promise({
success: true,
message: 'Billing disabled, cost update skipped',
+ usageExceeded: false,
data: {
billingEnabled: false,
processedAt: new Date().toISOString(),
@@ -202,6 +266,10 @@ async function updateCostInner(req: NextRequest, span: Span): Promise@`).
+ if (idempotencyKey?.includes('@')) {
+ return invalidBillingProtocolResponse(requestId, span)
+ }
const isMcp = source === 'mcp_copilot'
span.setAttributes({
@@ -312,6 +380,13 @@ async function updateCostInner(req: NextRequest, span: Span): Promise({
success: true,
+ ...usageVerdict,
data: {
userId,
cost,
@@ -415,7 +513,8 @@ async function updateCostInner(req: NextRequest, span: Span): Promise billingUsageMonitorMoc
vi.mock('@/lib/billing/core/plan', () => billingPlanMock)
+vi.mock('@/lib/billing/core/billing', () => billingCoreMock)
+
vi.mock('@/lib/billing/core/subscription', () => billingSubscriptionMock)
vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock)
@@ -137,7 +140,12 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/workspaces/utils', () => workspacesUtilsMock)
-import { validateCopilotApiKeyBodySchema } from '@/lib/api/contracts/copilot'
+import {
+ validateCopilotApiKeyBodySchema,
+ validateCopilotApiKeyContract,
+} from '@/lib/api/contracts/copilot'
+import { resetMidRunUsageCaches } from '@/lib/billing/core/mid-run-usage'
+import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache'
import { POST } from '@/app/api/copilot/api-keys/validate/route'
const { mockGetWorkspaceBillingSettings } = workspacesUtilsMockFns
@@ -146,7 +154,13 @@ const { mockAuthorizeOrganizationChatDelegation: mockAuthorizeOrganizationChat }
mothershipOrganizationChatsMockFns
const { mockDeriveBillingContext } = billingUsageLogMockFns
const { mockGetHighestPrioritySubscription } = billingPlanMockFns
-const { mockCheckServerSideUsageLimits } = billingUsageMonitorMockFns
+const { mockGetOrganizationSubscription } = billingCoreMockFns
+const {
+ mockCheckBillingBlocked,
+ mockCheckBillingEntityBlocked,
+ mockCheckServerSideUsageLimits,
+ mockCheckUsageStatus,
+} = billingUsageMonitorMockFns
const mockIsEnterprisePlan = billingSubscriptionMockFns.mockIsEnterprisePlan
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
@@ -237,6 +251,17 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
})
+ it("sends a new turn's usage refusal as the empty 402 its contract declares", async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))
+
+ expect(res.status).toBe(402)
+ expect(await res.text()).toBe('')
+ const refusalSchema = validateCopilotApiKeyContract.response.statusSchemas?.[402]
+ expect(refusalSchema?.safeParse(undefined).success).toBe(true)
+ })
+
it('preserves the actor member cap for markerless self-hosted admission', async () => {
mockCheckAttributedUsageLimits.mockResolvedValue({
isExceeded: true,
@@ -389,6 +414,9 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
})
it('admits a direct-v1 key without Redis while ignoring a local workspace ID', async () => {
+ mockSerializeAccountBillingDecisionHeader.mockImplementation((decision: object) =>
+ encodeURIComponent(JSON.stringify(decision))
+ )
mockGetUserEntityPermissions.mockResolvedValueOnce(null)
mockGetWorkspaceBillingSettings.mockResolvedValueOnce({
billedAccountUserId: 'different-owner',
@@ -415,8 +443,9 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
expect(mockGetWorkspaceBillingSettings).not.toHaveBeenCalled()
- expect(mockSerializeAccountBillingDecisionHeader).toHaveBeenCalledWith(ACCOUNT_BILLING_DECISION)
- expect(res.headers.get('x-sim-billing-account-decision')).toBe('serialized-account-decision')
+ expect(
+ JSON.parse(decodeURIComponent(res.headers.get('x-sim-billing-account-decision') ?? ''))
+ ).toEqual({ ...ACCOUNT_BILLING_DECISION, payerSubscriptionId: ACCOUNT_SUBSCRIPTION.id })
})
it('fails direct-v1 admission closed when its payer cannot be resolved', async () => {
@@ -506,7 +535,21 @@ describe('validation lifecycle purposes', () => {
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockAuthorizeCallback.mockReset().mockResolvedValue(undefined)
mockCheckContinuationBilling.mockReset().mockResolvedValue({ blocked: false })
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: false, currentUsage: 1, limit: 10 })
+ mockCheckBillingBlocked.mockResolvedValue({ blocked: false })
+ mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: false })
mockIsEnterprisePlan.mockResolvedValue(false)
+ mockGetOrganizationSubscription.mockResolvedValue({
+ id: 'sub-org-1',
+ referenceId: 'org-1',
+ plan: 'enterprise',
+ status: 'active',
+ periodStart: new Date(ATTRIBUTION.billingPeriod.start),
+ periodEnd: new Date(ATTRIBUTION.billingPeriod.end),
+ })
+ resetUsageGateCache()
+ resetMidRunUsageCaches()
})
it('defaults older callers to full admission and rejects unknown purposes', () => {
@@ -516,7 +559,7 @@ describe('validation lifecycle purposes', () => {
).toBe(false)
})
- it('checks original payer and current scope without repeating spend admission', async () => {
+ it('checks original payer, current scope, and the original payer spend', async () => {
const response = await POST(request(body, attributedHeaders))
expect(response.status).toBe(200)
expect(mockAuthorizeCallback).toHaveBeenCalledWith({ ...body, delegationId: requestId })
@@ -527,7 +570,6 @@ describe('validation lifecycle purposes', () => {
expect(mockAuthorizeCallback.mock.invocationCallOrder[0]).toBeLessThan(
mockCheckContinuationBilling.mock.invocationCallOrder[0]
)
- expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
@@ -553,6 +595,45 @@ describe('validation lifecycle purposes', () => {
expect(response.headers.get('x-sim-billing-account-decision')).toBeNull()
})
+ it('refuses a direct-v1 continuation whose account is over its usage limit', async () => {
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
+
+ const response = await POST(request(body, directHeaders))
+
+ expect(response.status).toBe(402)
+ await expect(response.json()).resolves.toMatchObject({
+ code: 'USAGE_LIMIT_EXCEEDED',
+ usageUpgrade: { reason: 'usage_limit' },
+ })
+ })
+
+ it("refuses a direct-v1 continuation with the card for its admitted payer's plan", async () => {
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
+ mockGetOrganizationSubscription.mockResolvedValue({
+ id: 'sub-account-org',
+ referenceId: 'account-org',
+ plan: 'team',
+ status: 'active',
+ seats: 4,
+ })
+ mockGetHighestPrioritySubscription.mockResolvedValue(null)
+
+ const response = await POST(request(body, directHeaders))
+
+ expect(response.status).toBe(402)
+ await expect(response.json()).resolves.toMatchObject({
+ usageUpgrade: {
+ action: 'increase_limit',
+ message: expect.stringContaining("organization's usage limit"),
+ },
+ })
+ })
+
+ it('admits a direct-v1 continuation whose usage cannot be read', async () => {
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, unavailable: true })
+ expect((await POST(request(body, directHeaders))).status).toBe(200)
+ })
+
it.each([
['missing attribution', { ...attributedHeaders, 'x-sim-billing-attribution': '' }],
[
@@ -638,11 +719,186 @@ describe('validation lifecycle purposes', () => {
})
it.each(['actor', 'payer'])('refuses a newly blocked %s on continuation', async (scope) => {
- mockCheckContinuationBilling.mockResolvedValueOnce({ blocked: true, scope })
- expect((await POST(request(body, attributedHeaders))).status).toBe(402)
+ mockCheckContinuationBilling.mockResolvedValueOnce({
+ blocked: true,
+ scope,
+ message: 'Billing account frozen.',
+ })
+ const response = await POST(request(body, attributedHeaders))
+ expect(response.status).toBe(402)
+ await expect(response.json()).resolves.toEqual({
+ code: 'BILLING_BLOCKED',
+ error: 'Billing account frozen.',
+ })
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
})
+ it('refuses a payer the usage gate finds blocked as blocked, without the usage card', async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({
+ isExceeded: true,
+ reason: 'billing_blocked',
+ message: 'Organization billing issue.',
+ scope: 'payer',
+ })
+ const response = await POST(request(body, attributedHeaders))
+ expect(response.status).toBe(402)
+ await expect(response.json()).resolves.toEqual({
+ code: 'BILLING_BLOCKED',
+ error: 'Organization billing issue.',
+ })
+ })
+
+ it('admits a polled continuation whose spend cannot be read', async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({
+ isExceeded: true,
+ reason: 'usage_unavailable',
+ })
+ expect((await POST(request(body, attributedHeaders))).status).toBe(200)
+ mockCheckAttributedUsageLimits.mockRejectedValue(new Error('ledger read timed out'))
+ expect((await POST(request(body, attributedHeaders))).status).toBe(200)
+ })
+
+ it('refuses a continuation over its usage limit with the card the worker writes', async () => {
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ const response = await POST(request(body, attributedHeaders))
+
+ expect(response.status).toBe(402)
+ await expect(response.json()).resolves.toEqual({
+ code: 'USAGE_LIMIT_EXCEEDED',
+ error: expect.stringContaining('usage limit'),
+ usageUpgrade: {
+ reason: 'usage_limit',
+ action: 'upgrade_plan',
+ message: expect.stringContaining('usage limit'),
+ },
+ })
+ })
+
+ it('answers a polled re-check from the cached admission and always re-reads a refusal', async () => {
+ for (let call = 0; call < 2; call++) queueTableRows(schemaMock.user, [{ id: 'user-1' }])
+ expect((await POST(request(body, attributedHeaders))).status).toBe(200)
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+ for (let poll = 0; poll < 2; poll++) {
+ expect((await POST(request(body, attributedHeaders))).status).toBe(200)
+ }
+
+ resetUsageGateCache()
+ expect((await POST(request(body, attributedHeaders))).status).toBe(402)
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+ expect((await POST(request(body, attributedHeaders))).status).toBe(200)
+ })
+
+ it('checks the payer saved at admission for a direct-v1 run whose actor changed orgs', async () => {
+ const endedDecision = {
+ ...ACCOUNT_BILLING_DECISION,
+ billingPeriod: { start: '2026-06-01T00:00:00.000Z', end: '2026-07-01T00:00:00.000Z' },
+ }
+ mockGetHighestPrioritySubscription.mockResolvedValue({
+ id: 'sub-new-org',
+ referenceId: 'new-org',
+ plan: 'team',
+ status: 'active',
+ periodStart: new Date('2026-07-01T00:00:00.000Z'),
+ periodEnd: new Date('2099-01-01T00:00:00.000Z'),
+ })
+ mockGetOrganizationSubscription.mockResolvedValue({
+ id: 'sub-account-org',
+ referenceId: 'account-org',
+ plan: 'team',
+ status: 'active',
+ periodStart: new Date('2026-07-01T00:00:00.000Z'),
+ periodEnd: new Date('2099-01-01T00:00:00.000Z'),
+ })
+ mockCheckUsageStatus.mockImplementation(
+ async (
+ _userId: string,
+ _subscription: unknown,
+ context?: { billingEntity: { id: string } }
+ ) => ({
+ isExceeded: context?.billingEntity.id === 'account-org',
+ currentUsage: 12,
+ limit: 10,
+ })
+ )
+
+ const response = await POST(
+ request(body, { ...directHeaders, 'x-sim-billing-account-decision': encode(endedDecision) })
+ )
+
+ expect(response.status).toBe(402)
+ })
+
+ it('answers repeated direct-v1 continuations from the cached admission and re-reads a refusal', async () => {
+ for (let call = 0; call < 2; call++) queueTableRows(schemaMock.user, [{ id: 'user-1' }])
+ expect((await POST(request(body, directHeaders))).status).toBe(200)
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
+ for (let leg = 0; leg < 2; leg++) {
+ expect((await POST(request(body, directHeaders))).status).toBe(200)
+ }
+
+ resetMidRunUsageCaches()
+ expect((await POST(request(body, directHeaders))).status).toBe(402)
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: false, currentUsage: 1, limit: 10 })
+ expect((await POST(request(body, directHeaders))).status).toBe(200)
+ })
+
+ it('never reads the usage gate for an attributed continuation when billing is off', async () => {
+ setEnvFlags({ isHosted: false, isBillingEnabled: false })
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: true, scope: 'payer' })
+
+ expect((await POST(request(body, attributedHeaders))).status).toBe(200)
+ })
+
+ it('never reads the ledger for a direct-v1 continuation when billing is off', async () => {
+ setEnvFlags({ isHosted: false, isBillingEnabled: false })
+ mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 12, limit: 10 })
+
+ expect((await POST(request(body, directHeaders))).status).toBe(200)
+ })
+
+ it('judges a direct-v1 reporting-window run against its admitted window after it ends', async () => {
+ const admittedWindow = {
+ ...ACCOUNT_BILLING_DECISION,
+ billingPeriod: {
+ start: '2026-06-01T00:00:00.000Z',
+ end: '2026-07-01T00:00:00.000Z',
+ source: 'reporting' as const,
+ },
+ }
+ mockCheckUsageStatus.mockImplementation(
+ async (
+ _userId: string,
+ _subscription: unknown,
+ context?: { billingPeriod: { start: Date } }
+ ) => ({
+ isExceeded:
+ context?.billingPeriod.start.toISOString() === admittedWindow.billingPeriod.start,
+ currentUsage: 12,
+ limit: 10,
+ })
+ )
+
+ const response = await POST(
+ request(body, { ...directHeaders, 'x-sim-billing-account-decision': encode(admittedWindow) })
+ )
+
+ expect(response.status).toBe(402)
+ })
+
+ it('judges a direct-v1 organization payer without a subscription as that organization', async () => {
+ mockGetOrganizationSubscription.mockResolvedValue(null)
+ mockCheckUsageStatus.mockImplementation(
+ async (_userId: string, subscription: { referenceId?: string } | null) => ({
+ isExceeded: subscription?.referenceId === 'account-org',
+ currentUsage: 12,
+ limit: 10,
+ })
+ )
+
+ expect((await POST(request(body, directHeaders))).status).toBe(402)
+ })
+
it('allows cancellation without billing material or spending/standing/plan checks', async () => {
const response = await POST(
request({ ...body, purpose: 'cancellation' }, { 'x-sim-billing-protocol': 'attribution-v1' })
@@ -752,7 +1008,6 @@ describe('validation lifecycle purposes', () => {
expect((await POST(request({ ...body, purpose: 'new-turn' }, attributedHeaders))).status).toBe(
402
)
- expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(1)
expect((await POST(request({ ...body, purpose: 'new-turn' }, directHeaders))).status).toBe(400)
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts
index 0b431ed12d4..0a1373dea56 100644
--- a/apps/sim/app/api/copilot/api-keys/validate/route.ts
+++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts
@@ -4,7 +4,13 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot'
+import {
+ COPILOT_BILLING_BLOCKED_CODE,
+ COPILOT_USAGE_LIMIT_EXCEEDED_CODE,
+ type ValidateCopilotApiKeyBillingBlocked,
+ type ValidateCopilotApiKeyUsageExceeded,
+ validateCopilotApiKeyContract,
+} from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import {
@@ -20,9 +26,14 @@ import {
serializeAccountBillingDecisionHeader,
serializeBillingAttributionHeader,
} from '@/lib/billing/core/billing-attribution'
+import {
+ readMidRunAccountUsageVerdict,
+ readMidRunUsageVerdict,
+} from '@/lib/billing/core/mid-run-usage'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
+import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -53,6 +64,8 @@ import { withIncomingGoSpan } from '@/lib/mothership/request/otel'
const logger = createLogger('CopilotApiKeysValidate')
+const CONTINUATION_BLOCKED_MESSAGE = 'Continuation billing account is blocked'
+
function invalidBillingProtocolResponse(): NextResponse {
return NextResponse.json({ error: 'Invalid billing attribution protocol' }, { status: 400 })
}
@@ -272,6 +285,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
? { source: billingContext.billingPeriod.source }
: {}),
},
+ ...(subscription ? { payerSubscriptionId: subscription.id } : {}),
},
}
}
@@ -402,13 +416,54 @@ export const POST = withRouteHandler((req: NextRequest) =>
blocked: blocked?.blocked ?? false,
elapsedMs: Math.round(performance.now() - startedAt),
})
- if (blocked?.blocked) {
+ // A continuation, and a worker's periodic re-check of a long run, also reads the
+ // original payer's spend through the cached execution usage gate. A read that fails
+ // admits: the run is already under way, and the next re-check reads again.
+ const verdict =
+ !blocked?.blocked && purpose === COPILOT_VALIDATION_PURPOSE.continuation && billing
+ ? billing.kind === 'attributed'
+ ? await readMidRunUsageVerdict(billing.attribution)
+ : await readMidRunAccountUsageVerdict(billing.decision)
+ : null
+ if (blocked?.blocked || verdict?.status === 'blocked') {
+ span.setAttribute(
+ TraceAttr.CopilotValidateOutcome,
+ CopilotValidateOutcome.UsageExceeded
+ )
+ span.setAttribute(TraceAttr.HttpStatusCode, 402)
+ return NextResponse.json(
+ {
+ code: COPILOT_BILLING_BLOCKED_CODE,
+ error:
+ (blocked?.blocked
+ ? blocked.message
+ : verdict?.status === 'blocked'
+ ? verdict.message
+ : undefined) ?? CONTINUATION_BLOCKED_MESSAGE,
+ },
+ { status: 402 }
+ )
+ }
+ if (verdict?.status === 'exceeded') {
+ logger.info('[API VALIDATION] Continuation usage exceeded', { userId })
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.UsageExceeded
)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
- return new NextResponse(null, { status: 402 })
+ const usageUpgrade = await resolveUsageUpgradePayload(
+ userId,
+ billing?.kind === 'attributed' ? billing.attribution : verdict.payer,
+ verdict.scope
+ )
+ return NextResponse.json(
+ {
+ code: COPILOT_USAGE_LIMIT_EXCEEDED_CODE,
+ error: usageUpgrade.message,
+ usageUpgrade,
+ },
+ { status: 402 }
+ )
}
const isEnterprise =
purpose === COPILOT_VALIDATION_PURPOSE.cancellation
diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts
index 105bcac4011..dde58162c57 100644
--- a/apps/sim/app/api/copilot/chat/queries.ts
+++ b/apps/sim/app/api/copilot/chat/queries.ts
@@ -5,9 +5,12 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work
import { toError } from '@sim/utils/errors'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript'
import { getAccessibleCopilotChat } from '@/lib/mothership/chat/lifecycle'
+import {
+ type LiveTurnSnapshot,
+ readLiveTurnSnapshot,
+} from '@/lib/mothership/chat/live-turn-snapshot'
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import {
authenticateCopilotRequestSessionOnly,
@@ -16,9 +19,6 @@ import {
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/mothership/request/http'
-import { readFilePreviewSessions } from '@/lib/mothership/request/session'
-import { readEvents } from '@/lib/mothership/request/session/buffer'
-import { toStreamBatchEvent } from '@/lib/mothership/request/session/types'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
@@ -87,43 +87,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
- let streamSnapshot: {
- events: ReturnType[]
- previewSessions: Awaited>
- status: string
- } | null = null
+ let streamSnapshot: LiveTurnSnapshot | null = null
if (chat.conversationId) {
try {
- const [events, previewSessions, run] = await Promise.all([
- readEvents(chat.conversationId, '0'),
- readFilePreviewSessions(chat.conversationId).catch((error) => {
- logger.warn('Failed to read preview sessions for copilot chat', {
- chatId,
- conversationId: chat.conversationId,
- error: toError(error).message,
- })
- return []
- }),
- getLatestRunForStream(chat.conversationId, authenticatedUserId).catch((error) => {
- logger.warn('Failed to fetch latest run for copilot chat snapshot', {
- chatId,
- conversationId: chat.conversationId,
- error: toError(error).message,
- })
- return null
- }),
- ])
-
- streamSnapshot = {
- events: events.map(toStreamBatchEvent),
- previewSessions,
- status:
- typeof run?.status === 'string'
- ? run.status
- : events.length > 0
- ? 'active'
- : 'unknown',
- }
+ streamSnapshot = await readLiveTurnSnapshot(chat.conversationId, authenticatedUserId)
} catch (error) {
logger.warn('Failed to load copilot chat stream snapshot', {
chatId,
diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts
index d7de092d2c1..f2815f0e665 100644
--- a/apps/sim/app/api/copilot/chat/stream/route.test.ts
+++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts
@@ -1,19 +1,29 @@
+import { trace } from '@opentelemetry/api'
+import {
+ BasicTracerProvider,
+ InMemorySpanExporter,
+ SimpleSpanProcessor,
+} from '@opentelemetry/sdk-trace-base'
import { authMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
} from '@/lib/mothership/generated/mothership-stream-v1'
+import { CopilotResumeOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1'
+import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
+import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1'
-const { getLatestRunForStream, readEvents, readFilePreviewSessions, checkForReplayGap } =
- vi.hoisted(() => ({
+const { getLatestRunForStream, readEvents, readFilePreviewSessions, findReplayGap } = vi.hoisted(
+ () => ({
getLatestRunForStream: vi.fn(),
readEvents: vi.fn(),
readFilePreviewSessions: vi.fn(),
- checkForReplayGap: vi.fn(),
- }))
+ findReplayGap: vi.fn(),
+ })
+)
vi.mock('@/lib/mothership/request/application/recover-stream', () => ({
readChatStream: { execute: getLatestRunForStream },
@@ -24,7 +34,10 @@ vi.mock('@/lib/mothership/request/session', () => ({
status === 'complete' || status === 'error' || status === 'cancelled',
readEvents,
readFilePreviewSessions,
- checkForReplayGap,
+ findReplayGap,
+ readRingPosition: async () => ({ requestedAfterSeq: 0, oldestSeq: 0, latestSeq: 0 }),
+ ringCanServe: () => true,
+ replayGapTerminal: async () => ({ gapDetected: true, envelopes: [] }),
createEvent: (event: Record) => ({
stream: {
streamId: event.streamId,
@@ -62,6 +75,10 @@ async function readAllChunks(response: Response): Promise {
}
describe('copilot chat stream replay route', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
beforeEach(() => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
@@ -69,7 +86,7 @@ describe('copilot chat stream replay route', () => {
})
readEvents.mockResolvedValue([])
readFilePreviewSessions.mockResolvedValue([])
- checkForReplayGap.mockResolvedValue(null)
+ findReplayGap.mockResolvedValue(null)
})
it('refuses replay after organization membership is removed', async () => {
@@ -171,4 +188,112 @@ describe('copilot chat stream replay route', () => {
expect(body).toContain('"code":"resume_run_unavailable"')
expect(body).toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
})
+
+ it('ends a still-running replay at its cap without a terminal so the client re-attaches', async () => {
+ const exporter = new InMemorySpanExporter()
+ trace.setGlobalTracerProvider(
+ new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
+ )
+ vi.useFakeTimers()
+ getLatestRunForStream.mockResolvedValue({
+ status: 'active',
+ executionId: 'exec-1',
+ id: 'run-1',
+ })
+
+ const response = await GET(
+ new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=7')
+ )
+ const body = readAllChunks(response)
+ await vi.advanceTimersByTimeAsync(61 * 60 * 1000)
+ const text = (await body).join('')
+
+ expect(text).toContain(': keepalive')
+ expect(text).not.toContain(`"type":"${MothershipStreamV1EventType.error}"`)
+ expect(text).not.toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
+ const resume = exporter
+ .getFinishedSpans()
+ .find((span) => span.name === TraceSpan.CopilotResumeRequest)
+ expect(resume?.attributes[TraceAttr.CopilotResumeOutcome]).toBe(
+ CopilotResumeOutcome.EndedWithoutTerminal
+ )
+ trace.disable()
+ })
+
+ it('never delivers a ring read that starts past the reader cursor, and ends without a terminal', async () => {
+ getLatestRunForStream.mockResolvedValue({
+ status: 'active',
+ executionId: 'exec-1',
+ id: 'run-1',
+ })
+ readEvents.mockResolvedValue([
+ {
+ stream: { streamId: 'stream-1', cursor: '5' },
+ seq: 5,
+ trace: { requestId: 'req-1' },
+ type: MothershipStreamV1EventType.text,
+ payload: { channel: 'assistant', text: 'the middle of the turn' },
+ },
+ ])
+
+ const response = await GET(
+ new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
+ )
+ const text = (await readAllChunks(response)).join('')
+
+ expect(text).not.toContain('the middle of the turn')
+ expect(text).not.toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
+ })
+
+ it('serves a batch read that starts past the reader cursor no events', async () => {
+ getLatestRunForStream.mockResolvedValue({
+ status: 'active',
+ executionId: 'exec-1',
+ id: 'run-1',
+ })
+ readEvents.mockResolvedValue([
+ {
+ stream: { streamId: 'stream-1', cursor: '5' },
+ seq: 5,
+ trace: { requestId: 'req-1' },
+ type: MothershipStreamV1EventType.text,
+ payload: { channel: 'assistant', text: 'the middle of the turn' },
+ },
+ ])
+
+ const response = await GET(
+ new NextRequest(
+ 'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0&batch=true'
+ )
+ )
+
+ await expect(response.json()).resolves.toMatchObject({ success: true, events: [] })
+ })
+
+ it('ends a live tail without a terminal when the ring trims past its cursor mid-tail', async () => {
+ getLatestRunForStream.mockResolvedValue({
+ status: 'active',
+ executionId: 'exec-1',
+ id: 'run-1',
+ })
+ const event = (seq: number, text: string) => ({
+ stream: { streamId: 'stream-1', cursor: String(seq) },
+ seq,
+ trace: { requestId: 'req-1' },
+ type: MothershipStreamV1EventType.text,
+ payload: { channel: 'assistant', text },
+ })
+ readEvents
+ .mockResolvedValueOnce([event(1, 'the start of the turn')])
+ .mockResolvedValue([event(5, 'past a trimmed gap')])
+
+ const response = await GET(
+ new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
+ )
+ const text = (await readAllChunks(response)).join('')
+
+ expect(text).toContain('the start of the turn')
+ expect(text).not.toContain('past a trimmed gap')
+ expect(text).not.toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
+ })
})
diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts
index c84cad4d102..3bdaeecf49d 100644
--- a/apps/sim/app/api/copilot/chat/stream/route.ts
+++ b/apps/sim/app/api/copilot/chat/stream/route.ts
@@ -13,6 +13,7 @@ import {
} from '@/lib/api/server/routes'
import { encodeSSEComment } from '@/lib/core/utils/sse'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
@@ -27,12 +28,18 @@ import { readChatStream } from '@/lib/mothership/request/application/recover-str
import { contextFromRequestHeaders } from '@/lib/mothership/request/go/propagation'
import { getCopilotTracer, markSpanForError } from '@/lib/mothership/request/otel'
import {
- checkForReplayGap,
createEvent,
encodeSSEEnvelope,
+ findReplayGap,
+ forwardRunReplay,
isTerminalStreamStatus,
+ openRunReplay,
+ RunReplayUnavailableError,
readEvents,
readFilePreviewSessions,
+ readRingPosition,
+ replayGapTerminal,
+ ringCanServe,
SSE_RESPONSE_HEADERS,
} from '@/lib/mothership/request/session'
import { toReplayEnvelope, toStreamBatchEvent } from '@/lib/mothership/request/session/types'
@@ -43,7 +50,23 @@ const logger = createLogger('CopilotChatStreamAPI')
const POLL_INTERVAL_MS = 250
const POLL_INTERVAL_MAX_MS = 2_000
const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000
-const MAX_STREAM_MS = 60 * 60 * 1000
+/** How often a tail that is still flushing events checks that its ring can serve it. */
+const RING_CHECK_EVERY_BUSY_POLLS = 8
+/**
+ * One replay response stays open at most this long, inside the route's `maxDuration`.
+ * A run still going at the cap is not over: the response ends without a terminal
+ * event and the client re-attaches from its cursor.
+ */
+const MAX_STREAM_MS = 60 * 60 * 1000 - 60_000
+
+/**
+ * Whether ring events read after `cursor` start right after it. The ring can trim its
+ * head between a gap check and the read, and a read that starts later would silently
+ * skip part of the turn.
+ */
+function startsAfterCursor(events: readonly { seq: number }[], cursor: string): boolean {
+ return events.length === 0 || events[0].seq <= Number(cursor || '0') + 1
+}
function extractCanonicalRequestId(value: unknown): string {
return typeof value === 'string' && value.length > 0 ? value : ''
@@ -124,7 +147,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(copilotChatStreamContract, request, {})
if (!parsed.success) return parsed.response
- const { streamId, after: afterCursor, batch: batchMode } = parsed.data.query
+ const { streamId, after: afterCursor, batch: batchMode, source } = parsed.data.query
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
@@ -168,6 +191,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
streamId,
afterCursor,
batchMode,
+ fromLog: source === 'log',
principal,
rootSpan,
rootContext,
@@ -190,6 +214,7 @@ async function handleResumeRequestBody({
streamId,
afterCursor,
batchMode,
+ fromLog,
principal,
rootSpan,
rootContext,
@@ -198,6 +223,8 @@ async function handleResumeRequestBody({
streamId: string
afterCursor: string
batchMode: boolean
+ /** The reader's cursor came from a log re-sync, so the ring never serves it. */
+ fromLog: boolean
principal: SessionPrincipal
rootSpan: Span
rootContext: Context
@@ -224,7 +251,8 @@ async function handleResumeRequestBody({
if (batchMode) {
const afterSeq = afterCursor || '0'
- const [events, previewSessions] = await Promise.all([
+ const [gap, events, previewSessions] = await Promise.all([
+ fromLog ? null : findReplayGap(streamId, afterSeq, extractRunRequestId(run)),
readEvents(streamId, afterSeq),
readFilePreviewSessions(streamId).catch((error) => {
logger.warn('Failed to read preview sessions for stream batch', {
@@ -234,7 +262,10 @@ async function handleResumeRequestBody({
return []
}),
])
- const batchEvents = events.map(toStreamBatchEvent)
+ // A reader the ring cannot serve, or whose next event it trimmed after the gap check,
+ // is re-synced from the worker log by the live tail.
+ const batchEvents =
+ fromLog || gap || !startsAfterCursor(events, afterSeq) ? [] : events.map(toStreamBatchEvent)
logger.info('[Resume] Batch response', {
streamId,
afterCursor: afterSeq,
@@ -261,10 +292,47 @@ async function handleResumeRequestBody({
let totalEventsFlushed = 0
let pollIterations = 0
+ /**
+ * A reader the ring cannot serve is re-synced from the worker's durable log for the
+ * rest of this response, never handed back to the ring: the log and the ring have
+ * no shared position to join on. The header tells the client to rebuild the turn
+ * from an empty response, since the replay's cursors restart at 1.
+ */
+ const ringGap = fromLog
+ ? null
+ : await findReplayGap(streamId, afterCursor || '0', extractRunRequestId(run))
+ // A finished run whose buffer expired answers its terminal; its transcript is persisted.
+ const gap =
+ ringGap && !(ringGap.latestSeq <= 0 && isTerminalStreamStatus(run.status)) ? ringGap : null
+ const resyncFromLog = fromLog || gap !== null
+ let replayBody: ReadableStream | null = null
+ /** Releases the worker's replay once this response ends; the request signal may never fire. */
+ const replayAbort = new AbortController()
+ const replaySignal = AbortSignal.any([request.signal, replayAbort.signal])
+ if (resyncFromLog && run.chatId) {
+ try {
+ replayBody = await openRunReplay({
+ streamId,
+ chatId: run.chatId,
+ userId: principal.userId,
+ signal: replaySignal,
+ })
+ } catch (error) {
+ if (!(error instanceof RunReplayUnavailableError)) throw error
+ logger.warn('Run replay unavailable; the client will retry', {
+ streamId,
+ error: getErrorMessage(error),
+ })
+ markSpanForError(rootSpan, error)
+ rootSpan.end()
+ return NextResponse.json({ error: 'Stream replay is unavailable' }, { status: 503 })
+ }
+ }
+
const stream = new ReadableStream({
async start(controller) {
// Re-enter the root OTel context so any `withCopilotSpan` call below
- // (inside flushEvents/checkForReplayGap/etc.) parents under
+ // (inside flushEvents/replayGapTerminal/etc.) parents under
// copilot.resume.request instead of becoming an orphan.
return otelContext.with(rootContext, () => startInner(controller))
},
@@ -326,8 +394,13 @@ async function handleResumeRequestBody({
}
request.signal.addEventListener('abort', abortListener, { once: true })
- const flushEvents = async (): Promise => {
+ /** Delivers the ring's events after the cursor, or returns null if it trimmed the next one. */
+ const flushEvents = async (): Promise => {
const events = await readEvents(streamId, cursor)
+ if (!startsAfterCursor(events, cursor)) {
+ logger.warn('Replay ring trimmed past a reader cursor', { streamId, cursor })
+ return null
+ }
if (events.length > 0) {
logger.debug('[Resume] Flushing events', {
streamId,
@@ -375,12 +448,46 @@ async function handleResumeRequestBody({
}
}
+ /** Forwards the worker's replay, keeping the response alive while it waits. */
+ const streamRunReplay = async (body: ReadableStream) => {
+ const keepalive = setInterval(() => {
+ if (Date.now() - lastWriteTime < REPLAY_KEEPALIVE_INTERVAL_MS) return
+ if (!enqueueComment('keepalive')) replayAbort.abort()
+ }, REPLAY_KEEPALIVE_INTERVAL_MS)
+ try {
+ const end = await forwardRunReplay({
+ body,
+ streamId,
+ signal: replaySignal,
+ write: (envelope) => {
+ if (!enqueueEvent(envelope)) return false
+ totalEventsFlushed += 1
+ cursor = envelope.stream.cursor ?? cursor
+ if (envelope.type === MothershipStreamV1EventType.complete) sawTerminalEvent = true
+ return true
+ },
+ readRunStatus: async () => (await readRun().catch(() => null))?.status ?? null,
+ isClosed: () => controllerClosed,
+ deadlineAt: startTime + MAX_STREAM_MS,
+ })
+ logger.info('[Resume] Run replay ended', { streamId, end, eventCount: totalEventsFlushed })
+ } finally {
+ clearInterval(keepalive)
+ replayAbort.abort()
+ }
+ }
+
try {
enqueueComment('accepted')
- const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId)
- if (gap) {
- for (const envelope of gap.envelopes) {
+ if (replayBody) {
+ await streamRunReplay(replayBody)
+ return
+ }
+ if (resyncFromLog) {
+ const position = gap ?? (await readRingPosition(streamId, cursor))
+ const terminal = await replayGapTerminal(streamId, position, currentRequestId)
+ for (const envelope of terminal.envelopes) {
if (!enqueueEvent(envelope)) {
break
}
@@ -393,7 +500,8 @@ async function handleResumeRequestBody({
return
}
- await flushEvents()
+ let lastFlushed = await flushEvents()
+ if (lastFlushed === null) return
let pollDelayMs = POLL_INTERVAL_MS
while (!controllerClosed && Date.now() - startTime < MAX_STREAM_MS) {
@@ -413,10 +521,24 @@ async function handleResumeRequestBody({
})
break
}
+ // The ring lost its head, restarted or expired under this live tail; the re-attach
+ // re-syncs, and a finished run answers its terminal instead. Only a quiet ring can
+ // restart or be re-sent into by a recovery, so a busy tail checks every few polls.
+ const checkRing = lastFlushed === 0 || pollIterations % RING_CHECK_EVERY_BUSY_POLLS === 0
+ if (
+ checkRing &&
+ !isTerminalStreamStatus(currentRun.status) &&
+ !ringCanServe(await readRingPosition(streamId, cursor))
+ ) {
+ logger.warn('Replay ring can no longer serve a live tail', { streamId, cursor })
+ break
+ }
currentRequestId = extractRunRequestId(currentRun) || currentRequestId
const flushed = await flushEvents()
+ if (flushed === null) break
+ lastFlushed = flushed
/* Adaptive tail: 4 Hz only while events are actually flowing; a quiet stream
decays toward the cap so an attached client doesn't hammer Postgres + Redis
at 4 Hz for up to an hour. Any flushed event snaps back to full rate. */
@@ -451,13 +573,6 @@ async function handleResumeRequestBody({
await sleep(pollDelayMs)
}
- if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) {
- emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
- message: 'The stream recovery timed out before completion.',
- code: 'resume_timeout',
- reason: 'timeout',
- })
- }
} catch (error) {
if (!controllerClosed && !request.signal.aborted) {
logger.warn('Stream replay failed', {
@@ -473,11 +588,13 @@ async function handleResumeRequestBody({
markSpanForError(rootSpan, error)
} finally {
request.signal.removeEventListener('abort', abortListener)
+ // Read before closing: closing the controller here is this route ending, not the client.
+ const clientDisconnected = controllerClosed
closeController()
rootSpan.setAttributes({
[TraceAttr.CopilotResumeOutcome]: sawTerminalEvent
? CopilotResumeOutcome.TerminalDelivered
- : controllerClosed
+ : clientDisconnected
? CopilotResumeOutcome.ClientDisconnected
: CopilotResumeOutcome.EndedWithoutTerminal,
[TraceAttr.CopilotResumeEventCount]: totalEventsFlushed,
@@ -488,5 +605,9 @@ async function handleResumeRequestBody({
}
}
- return new Response(stream, { headers: SSE_RESPONSE_HEADERS })
+ return new Response(stream, {
+ headers: replayBody
+ ? { ...SSE_RESPONSE_HEADERS, [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' }
+ : SSE_RESPONSE_HEADERS,
+ })
}
diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts
index 3ced83827e4..4d6beb92521 100644
--- a/apps/sim/app/api/copilot/tools/execute/route.ts
+++ b/apps/sim/app/api/copilot/tools/execute/route.ts
@@ -14,6 +14,7 @@ import { withIncomingGoSpan } from '@/lib/mothership/request/otel'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
+ measureWithheldContent,
projectToolErrorMessageForCopilot,
} from '@/lib/mothership/request/tools/resolved-secret-result'
import { handleResourceSideEffects } from '@/lib/mothership/request/tools/resources'
@@ -235,6 +236,7 @@ export const POST = withRouteHandler((request: NextRequest) =>
toolCallId,
runtimeSucceeded: result.success,
...describeWithholdingCause(projection.cause),
+ ...measureWithheldContent(result),
})
}
if (!projected.success) {
diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts
index 755a68094b2..7868fb0a087 100644
--- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts
+++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts
@@ -23,11 +23,13 @@ export function createCredentialGroupEnrollmentRedirect(
export function createCredentialGroupCompletionRedirect(
oauth?: CredentialGroupOAuthFailure,
- completionId?: string
+ completionId?: string,
+ organizationId?: string
): NextResponse {
const query = new URLSearchParams()
if (oauth) query.set('oauth', oauth)
if (completionId) query.set('completionId', completionId)
+ if (organizationId) query.set('organizationId', organizationId)
return new NextResponse(null, {
status: 303,
headers: {
diff --git a/apps/sim/app/api/credential-groups/oauth-callback.test.ts b/apps/sim/app/api/credential-groups/oauth-callback.test.ts
index 6cdae266ed9..1f118b2b028 100644
--- a/apps/sim/app/api/credential-groups/oauth-callback.test.ts
+++ b/apps/sim/app/api/credential-groups/oauth-callback.test.ts
@@ -238,3 +238,33 @@ describe('GitHub installation setup OAuth return target', () => {
expect(url.searchParams.get('setupId')).toBe(completionId)
})
})
+
+describe('Integrations OAuth completion', () => {
+ it.each([undefined, 'denied'])(
+ 'returns the originating organization on completion: %s',
+ async (error) => {
+ mocks.consumeAttempt.mockResolvedValueOnce({
+ ...attempt,
+ returnTo: 'integrations',
+ organizationId: 'organization-1',
+ completionRedirect: true,
+ completionId,
+ })
+ mocks.authenticate.mockResolvedValueOnce({ kind: 'credential_group_enrollment' })
+ mocks.completeOAuth.mockResolvedValueOnce({ connectedOptionId: 'option-1' })
+ const response = await handleCredentialGroupOAuthCallback({
+ request: createMockRequest({
+ url: 'https://sim.test/api/auth/oauth2/callback/github-repositories',
+ }),
+ provider: 'github-repositories',
+ query: { state: 'cg_state', code: 'code-1', ...(error ? { error } : {}) },
+ limited: null,
+ })
+ const destination = new URL(response.headers.get('location')!, 'https://sim.test')
+ expect(destination.pathname).toBe('/credential-groups/complete')
+ expect(destination.searchParams.get('completionId')).toBe(completionId)
+ expect(destination.searchParams.get('organizationId')).toBe('organization-1')
+ expect(destination.searchParams.get('oauth')).toBe(error ?? null)
+ }
+ )
+})
diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts
index 0d8b0240ac2..f88f8e79a20 100644
--- a/apps/sim/app/api/credential-groups/oauth-callback.ts
+++ b/apps/sim/app/api/credential-groups/oauth-callback.ts
@@ -84,11 +84,13 @@ export async function handleCredentialGroupOAuthCallback({
})
const installationSetup =
attempt.returnTo === 'github-installation' && attempt.organizationId && attempt.completionId
+ const returnOrganizationId =
+ attempt.returnTo === 'integrations' ? attempt.organizationId : undefined
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
installationSetup
? setupRedirect(oauth)
: attempt.completionRedirect
- ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId)
+ ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId, returnOrganizationId)
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
if (limited) {
return failureRedirect('rate_limited')
@@ -117,7 +119,11 @@ export async function handleCredentialGroupOAuthCallback({
request,
})
return attempt.completionRedirect
- ? createCredentialGroupCompletionRedirect(undefined, attempt.completionId)
+ ? createCredentialGroupCompletionRedirect(
+ undefined,
+ attempt.completionId,
+ returnOrganizationId
+ )
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
...focus,
connected: attempt.optionId,
diff --git a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.test.ts b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.test.ts
new file mode 100644
index 00000000000..9212f0f743b
--- /dev/null
+++ b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.test.ts
@@ -0,0 +1,18 @@
+import { authMockFns } from '@sim/testing/mocks/auth.mock'
+import { createMockRequest } from '@sim/testing/mocks/request.mock'
+import { expect, it } from 'vitest'
+import { GET } from '@/app/api/credential-groups/slack-managed-users/callback/route'
+
+it('preserves sign-in recovery when the managed Slack callback loses its session', async () => {
+ authMockFns.mockGetSession.mockResolvedValueOnce(null)
+ const response = await GET(
+ createMockRequest({
+ url: 'http://localhost/api/credential-groups/slack-managed-users/callback?state=fixture-state&code=fixture-code',
+ })
+ )
+ expect(response.status).toBe(303)
+ const location = new URL(response.headers.get('location')!)
+ expect(location.pathname).toBe('/credential-groups/slack-complete')
+ expect(location.searchParams.get('state')).toBe('fixture-state')
+ expect(location.searchParams.get('reason')).toBe('signin_required')
+})
diff --git a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts
index 441e0978439..3299729d20b 100644
--- a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts
+++ b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts
@@ -6,26 +6,12 @@ import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/con
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users'
import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users'
const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI')
-const CHANNEL_NAME = 'slack-managed-users'
-
-function escapeHtml(value: string): string {
- return value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
-function jsonLiteral(value: unknown): string {
- return JSON.stringify(value).replace(//g, '\\u003e')
-}
-
function closePopup(params: {
ok: boolean
message: string
@@ -34,24 +20,16 @@ function closePopup(params: {
slackBotCredentialId?: string
reason: string
}): NextResponse {
- const title = params.ok ? 'Slack configured' : 'Slack setup failed'
- const payload = {
- type: CHANNEL_NAME,
- ok: params.ok,
- state: params.state,
- credentialGroupId: params.credentialGroupId,
- slackBotCredentialId: params.slackBotCredentialId,
- reason: params.reason,
+ const url = new URL('/credential-groups/slack-complete', getBaseUrl())
+ url.searchParams.set('mode', 'managed')
+ url.searchParams.set('ok', String(params.ok))
+ for (const key of ['state', 'credentialGroupId', 'slackBotCredentialId', 'reason'] as const) {
+ const value = params[key]
+ if (value) url.searchParams.set(key, value)
}
- const body = `${title} ${escapeHtml(params.message)}
`
- return new NextResponse(body, {
- headers: {
- 'Cache-Control': 'no-store, max-age=0',
- 'Content-Type': 'text/html; charset=utf-8',
- },
+ return NextResponse.redirect(url, {
+ status: 303,
+ headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' },
})
}
@@ -63,7 +41,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
ok: false,
message: 'Sign in to Sim to complete this Slack setup.',
state: rawState,
- reason: 'unauthenticated',
+ reason: 'signin_required',
})
}
const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {})
diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts
index c920c86826d..0eba10da8cc 100644
--- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts
+++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts
@@ -32,6 +32,7 @@ import {
STALE_SWEEPABLE_EXECUTION_STATUSES,
type StaleSweepableExecutionStatus,
} from '@/lib/logs/types'
+import { sweepOrphanedRuns } from '@/lib/mothership/async-runs/orphaned-runs'
import { cancelStaleDispatches } from '@/lib/table/dispatcher'
import { deleteFile } from '@/lib/uploads/core/storage-service'
import {
@@ -738,6 +739,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
}
+ /**
+ * Settle Chat runs no controller will finish: their process died, their
+ * controller was superseded without a successor, or Stop found none. Without
+ * this they stay unfinished forever and keep their chat marked as busy.
+ */
+ let orphanedRunsSettled = 0
+ try {
+ orphanedRunsSettled = (await sweepOrphanedRuns()).settledRunIds.length
+ } catch (error) {
+ logger.error('Failed to settle orphaned Chat runs:', {
+ error: toError(error).message,
+ })
+ }
+
return NextResponse.json({
success: true,
executions: {
@@ -768,6 +783,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
pruned: deploymentOperationsPruned,
retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS,
},
+ chatRuns: {
+ orphanedSettled: orphanedRunsSettled,
+ },
})
} catch (error) {
logger.error('Error in stale execution cleanup job:', error)
diff --git a/apps/sim/app/api/desktop/source-connect/consume/route.ts b/apps/sim/app/api/desktop/source-connect/consume/route.ts
new file mode 100644
index 00000000000..daa8d44b060
--- /dev/null
+++ b/apps/sim/app/api/desktop/source-connect/consume/route.ts
@@ -0,0 +1,23 @@
+import {
+ consumeDesktopSourceRequestContract,
+ desktopSourceRequestSchema,
+} from '@/lib/api/contracts/desktop-source-connect'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { consumeDesktopSourceRequest } from '@/lib/desktop/application/source-requests'
+
+export const POST = defineInternalJsonRoute({
+ contract: consumeDesktopSourceRequestContract,
+ auth: internalSessionAuth,
+ operation: consumeDesktopSourceRequest.operation,
+ rateLimit: internalRateLimits.user({ bucketName: 'desktop-source-connect' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ body }) => body,
+ useCase: consumeDesktopSourceRequest,
+ present: ({ payload }) => desktopSourceRequestSchema.parse(JSON.parse(payload)),
+ staticResponseHeaders: { 'Cache-Control': 'no-store' },
+})
diff --git a/apps/sim/app/api/desktop/source-connect/route.test.ts b/apps/sim/app/api/desktop/source-connect/route.test.ts
new file mode 100644
index 00000000000..1f3a404b136
--- /dev/null
+++ b/apps/sim/app/api/desktop/source-connect/route.test.ts
@@ -0,0 +1,32 @@
+import { authMockFns } from '@sim/testing/mocks/auth.mock'
+import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock'
+import { createMockRequest } from '@sim/testing/mocks/request.mock'
+import { expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock)
+
+import { POST } from '@/app/api/desktop/source-connect/route'
+
+it.each([true, false])(
+ 'rejects an oversized desktop request before JSON decoding (declared length: %s)',
+ async (declaredLength) => {
+ authMockFns.mockGetSession.mockResolvedValueOnce({
+ user: { id: 'fixture-user' },
+ session: { id: 'fixture-session' },
+ })
+ rateLimiterMockFns.mockEnforceUserRateLimit.mockResolvedValueOnce(null)
+ const rawBody = ' '.repeat(64 * 1024 + 1)
+ const response = await POST(
+ createMockRequest({
+ method: 'POST',
+ url: 'http://localhost/api/desktop/source-connect',
+ rawBody,
+ headers: {
+ 'content-type': 'application/json',
+ ...(declaredLength ? { 'content-length': String(rawBody.length) } : {}),
+ },
+ })
+ )
+ expect(response.status).toBe(413)
+ }
+)
diff --git a/apps/sim/app/api/desktop/source-connect/route.ts b/apps/sim/app/api/desktop/source-connect/route.ts
new file mode 100644
index 00000000000..e44102784b3
--- /dev/null
+++ b/apps/sim/app/api/desktop/source-connect/route.ts
@@ -0,0 +1,20 @@
+import { createDesktopSourceRequestContract } from '@/lib/api/contracts/desktop-source-connect'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { createDesktopSourceRequest } from '@/lib/desktop/application/source-requests'
+
+export const POST = defineInternalJsonRoute({
+ contract: createDesktopSourceRequestContract,
+ auth: internalSessionAuth,
+ operation: createDesktopSourceRequest.operation,
+ rateLimit: internalRateLimits.user({ bucketName: 'desktop-source-connect' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ parseOptions: { maxBodyBytes: 64 * 1024 },
+ mapInput: ({ body }) => ({ requestId: body.requestId, payload: JSON.stringify(body.request) }),
+ useCase: createDesktopSourceRequest,
+ staticResponseHeaders: { 'Cache-Control': 'no-store' },
+})
diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts
index eabe6069a0a..e70ab6b34d8 100644
--- a/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts
+++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts
@@ -63,7 +63,7 @@ describe('Slack OAuth callback', () => {
const response = await GET(request('state=state&code=code'))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe(
- 'https://www.sim.ai/o/org1/settings/search-slack?slackSetup=complete'
+ 'https://www.sim.ai/credential-groups/slack-complete?state=state&ok=true&organizationId=org1'
)
expect(m.complete).toHaveBeenCalledWith(
expect.objectContaining({
@@ -75,12 +75,20 @@ describe('Slack OAuth callback', () => {
})
it('never falls back to public install on an invalid nonempty state', async () => {
m.complete.mockRejectedValueOnce(new OrchestrationError('validation', 'Expired state'))
- expect((await GET(request('state=expired&code=code'))).status).toBe(400)
+ const response = await GET(request('state=expired&code=code'))
+ expect(response.status).toBe(303)
+ expect(response.headers.get('location')).toBe(
+ 'https://www.sim.ai/credential-groups/slack-complete?state=expired&ok=false'
+ )
expect(m.authenticate).not.toHaveBeenCalled()
})
it('still requires a Sim session for an org-bound state', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
- expect((await GET(request('state=state&code=code'))).status).toBe(401)
+ const response = await GET(request('state=state&code=code'))
+ expect(response.status).toBe(303)
+ expect(response.headers.get('location')).toBe(
+ 'https://www.sim.ai/credential-groups/slack-complete?state=state&ok=false&reason=signin_required'
+ )
expect(m.authenticate).not.toHaveBeenCalled()
expect(m.complete).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts
index b6155d6f5c3..3b7c0983c9f 100644
--- a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts
+++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts
@@ -1,3 +1,5 @@
+import { createLogger } from '@sim/logger'
+import { describeError } from '@sim/utils/errors'
import { NextResponse } from 'next/server'
import { slackSearchOAuthCallbackContract } from '@/lib/api/contracts/knowledge/slack'
import { parseRequest } from '@/lib/api/server'
@@ -12,12 +14,14 @@ import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
-import { organizationRoutes } from '@/lib/navigation/paths'
import { slackSearchInstallPath } from '@/lib/slack-search/install-link'
import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth'
+const logger = createLogger('SlackSearchOAuthCallback')
+
/** OAuth is a redirect protocol; protected configuration remains in the application use case. */
export const GET = withRouteHandler(async (request) => {
+ let callbackState: string | undefined
try {
const limited = await enforceIpRateLimit('slack-search-oauth-callback', request)
if (limited) return limited
@@ -31,6 +35,7 @@ export const GET = withRouteHandler(async (request) => {
)
if (!parsed.success) return parsed.response
const { state, code, error } = parsed.data.query
+ callbackState = state
if (!state) {
if (error || !code)
throw new OrchestrationError(
@@ -53,13 +58,32 @@ export const GET = withRouteHandler(async (request) => {
input: { state, code, error },
request,
})
- const url = new URL(
- organizationRoutes(result.organizationId).settingsSection('search-slack'),
- getBaseUrl()
- )
- url.searchParams.set('slackSetup', 'complete')
- return NextResponse.redirect(url, 303)
+ const url = new URL('/credential-groups/slack-complete', getBaseUrl())
+ url.searchParams.set('state', state)
+ url.searchParams.set('ok', 'true')
+ url.searchParams.set('organizationId', result.organizationId)
+ return NextResponse.redirect(url, {
+ status: 303,
+ headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' },
+ })
} catch (error) {
+ if (callbackState) {
+ const projected = internalOrchestrationErrorPolicy.project(error)
+ if (
+ !(error instanceof InternalUnauthenticatedError) &&
+ (!projected || projected.status >= 500)
+ )
+ logger.error('Slack authorization callback failed', { error: describeError(error) })
+ const url = new URL('/credential-groups/slack-complete', getBaseUrl())
+ url.searchParams.set('state', callbackState)
+ url.searchParams.set('ok', 'false')
+ if (error instanceof InternalUnauthenticatedError)
+ url.searchParams.set('reason', 'signin_required')
+ return NextResponse.redirect(url, {
+ status: 303,
+ headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' },
+ })
+ }
if (error instanceof InternalUnauthenticatedError)
return NextResponse.json(
{ error: 'Sign in to Sim and restart Slack setup.' },
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
index 95c2b7e3670..d9b38f12508 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
@@ -38,7 +38,7 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforcePublicCredentialGroupIpRateLimit: mockEnforceCallbackRateLimit,
}))
-import { GET } from './route'
+import { GET } from '@/app/api/mcp/oauth/callback/route'
const { mockDiscoverServerTools } = mcpServiceMockFns
@@ -88,6 +88,31 @@ describe('MCP OAuth callback route', () => {
mockEnforceCallbackRateLimit.mockResolvedValue(null)
})
+ it.each([undefined, 'denied'])(
+ 'finishes a direct connection without the invitation form: %s',
+ async (error) => {
+ const completionId = '00000000-0000-4000-8000-000000000002'
+ mockConsumeManagedAttempt.mockResolvedValueOnce({
+ state: 'mcp_cg_direct',
+ organizationId: 'organization-1',
+ invitationToken: 'invitation-token',
+ mcpServerId: 'server-1',
+ completionId,
+ returnTo: 'integrations',
+ })
+ const response = await GET(
+ new NextRequest(
+ `http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_direct&${error ? 'error=denied' : 'code=code-1'}`
+ )
+ )
+ const destination = new URL(response.headers.get('location')!, 'http://localhost:3000')
+ expect(destination.pathname).toBe('/credential-groups/complete')
+ expect(destination.searchParams.get('completionId')).toBe(completionId)
+ expect(destination.searchParams.get('organizationId')).toBe('organization-1')
+ expect(destination.searchParams.get('oauth')).toBe(error ?? null)
+ }
+ )
+
it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
const request = new NextRequest(
'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index 6b7655ec2b0..4b97c7ba00e 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -17,6 +17,7 @@ import {
isCredentialGroupMcpOAuthState,
} from '@/lib/credential-groups/mcp-oauth-state'
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
+import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import {
assertSafeOauthServerUrl,
@@ -30,7 +31,10 @@ import {
SimMcpOauthProvider,
} from '@/lib/mcp/oauth'
import { mcpService } from '@/lib/mcp/service'
-import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
+import {
+ createCredentialGroupCompletionRedirect,
+ createCredentialGroupEnrollmentRedirect,
+} from '@/app/api/credential-groups/enrollment-redirect'
const logger = createLogger('McpOauthCallbackAPI')
const timedStep = makeTimedStep(logger)
@@ -98,13 +102,17 @@ async function completeManagedMcpCallback(params: {
if (!attempt) {
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
}
- if (params.error) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
- }
+ const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
+ attempt.completionId
+ ? createCredentialGroupCompletionRedirect(
+ oauth,
+ attempt.completionId,
+ attempt.returnTo === 'integrations' ? attempt.organizationId : undefined
+ )
+ : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth })
+ if (params.error) return failureRedirect('denied')
if (!params.code) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- oauth: 'failed',
- })
+ return failureRedirect('failed')
}
try {
const principal = await credentialGroupOAuthAttemptPrincipal(attempt)
@@ -113,13 +121,19 @@ async function completeManagedMcpCallback(params: {
input: { attempt, code: params.code },
request: params.request,
})
+ if (attempt.completionId)
+ return createCredentialGroupCompletionRedirect(
+ undefined,
+ attempt.completionId,
+ attempt.returnTo === 'integrations' ? attempt.organizationId : undefined
+ )
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
mcp: 'connected',
mcpServerId: result.mcpServerId,
})
} catch (error) {
logger.error('Managed MCP OAuth callback failed', error)
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
+ return failureRedirect('failed')
}
}
diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts
index 9251aa74f12..62316bdd8f8 100644
--- a/apps/sim/app/api/mothership/chat/route.ts
+++ b/apps/sim/app/api/mothership/chat/route.ts
@@ -10,6 +10,11 @@ import { handleUnifiedChatPost } from '@/lib/mothership/chat/post'
import { validateShimEnvelope } from '@/lib/mothership/request/http'
import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries'
+/**
+ * Caps this response on serverless hosts only; the Node server bounds nothing with it.
+ * The run does not depend on this response: one that ends without a terminal is
+ * re-attached through the replay stream.
+ */
export const maxDuration = 3600
// Unified chat route surface.
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
index f39b55f32af..bd975a2dd99 100644
--- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts
+++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
@@ -11,12 +11,15 @@ import {
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript'
import {
getAccessibleCopilotChatAuth,
getAccessibleCopilotChatWithMessages,
} from '@/lib/mothership/chat/lifecycle'
+import {
+ type LiveTurnSnapshot,
+ readLiveTurnSnapshot,
+} from '@/lib/mothership/chat/live-turn-snapshot'
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness'
import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
@@ -25,10 +28,6 @@ import {
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/mothership/request/http'
-import type { FilePreviewSession } from '@/lib/mothership/request/session'
-import { readEvents } from '@/lib/mothership/request/session/buffer'
-import { readFilePreviewSessions } from '@/lib/mothership/request/session/file-preview-session'
-import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/mothership/request/session/types'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('MothershipChatAPI')
@@ -55,11 +54,7 @@ export const GET = withRouteHandler(
// to the client: when `activeStreamId` is set, the client reconnects to
// the replay buffer (from seq 0) via the stream resume endpoint, which
// is the source of truth for streaming state.
- let liveTurnSnapshot: {
- events: StreamBatchEvent[]
- previewSessions: FilePreviewSession[]
- status: string
- } | null = null
+ let liveTurnSnapshot: LiveTurnSnapshot | null = null
const reconciledMarkers = await reconcileChatStreamMarkers(
[{ chatId: chat.id, streamId: chat.conversationId }],
@@ -69,36 +64,7 @@ export const GET = withRouteHandler(
if (liveStreamId) {
try {
- const [events, previewSessions] = await Promise.all([
- readEvents(liveStreamId, '0'),
- readFilePreviewSessions(liveStreamId).catch((error) => {
- logger.warn('Failed to read preview sessions for mothership chat', {
- chatId,
- streamId: liveStreamId,
- error: toError(error).message,
- })
- return []
- }),
- ])
- const run = await getLatestRunForStream(liveStreamId, userId).catch((error) => {
- logger.warn('Failed to fetch latest run for mothership chat snapshot', {
- chatId,
- streamId: liveStreamId,
- error: toError(error).message,
- })
- return null
- })
-
- liveTurnSnapshot = {
- events: events.map(toStreamBatchEvent),
- previewSessions,
- status:
- typeof run?.status === 'string'
- ? run.status
- : events.length > 0
- ? 'active'
- : 'unknown',
- }
+ liveTurnSnapshot = await readLiveTurnSnapshot(liveStreamId, userId)
} catch (error) {
logger.warn('Failed to read stream snapshot for mothership chat', {
chatId,
diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index 3e397eb70ac..3b460d1e910 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -48,6 +48,7 @@ import {
import type { ChatContext } from '@/stores/panel'
import { hasToolId } from '@/tools/tool-ids'
+/** Caps this response on serverless hosts only; the Node server bounds nothing with it. */
export const maxDuration = 3600
const logger = createLogger('MothershipExecuteAPI')
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts
index bb97cde1d08..51aa2a4a346 100644
--- a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts
@@ -21,6 +21,7 @@ export const POST = defineInternalJsonRoute({
mapInput: ({ params, body }) => ({
organizationId: params.id,
...body,
+ ...(body.oauthCompletionId ? { returnTo: 'integrations' as const } : {}),
}),
useCase: startOrganizationAccountConnection,
})
diff --git a/apps/sim/app/api/table/[tableId]/analytics/route.test.ts b/apps/sim/app/api/table/[tableId]/analytics/route.test.ts
new file mode 100644
index 00000000000..7b6b23bd608
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/analytics/route.test.ts
@@ -0,0 +1,55 @@
+import { authMockFns } from '@sim/testing/mocks/auth.mock'
+import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock'
+import { createMockRequest } from '@sim/testing/mocks/request.mock'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { POST } from '@/app/api/table/[tableId]/analytics/route'
+
+const hoisted = vi.hoisted(() => ({ execute: vi.fn() }))
+vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock)
+vi.mock('@/lib/table/application/analytics', () => ({
+ readTableAnalytics: { operation: { id: 'tables.rows.analytics' }, execute: hoisted.execute },
+}))
+const mocks = {
+ ...hoisted,
+ session: authMockFns.mockGetSession,
+ limit: rateLimiterMockFns.mockEnforceUserRateLimit,
+}
+const context = { params: Promise.resolve({ tableId: 'tbl_test' }) }
+const body = {
+ workspaceId: 'workspace_test',
+ query: {
+ from: '2026-09-01T00:00:00Z',
+ to: '2026-09-02T00:00:00Z',
+ aggregate: { n: { op: 'count' } },
+ },
+}
+const request = (value: unknown) =>
+ createMockRequest({ method: 'POST', url: '/api/table/tbl_test/analytics', body: value })
+beforeEach(() => {
+ mocks.session.mockResolvedValue({ user: { id: 'viewer' }, session: { id: 'session' } })
+ mocks.limit.mockResolvedValue(null)
+ mocks.execute.mockResolvedValue({
+ rows: [{ n: 0 }],
+ columns: ['n'],
+ columnLabels: { n: 'n' },
+ truncated: false,
+ bucket: null,
+ })
+})
+describe('analytics HTTP adapter', () => {
+ it('authenticates before parsing and never uses a file share as authority', async () => {
+ mocks.session.mockResolvedValue(null)
+ expect((await POST(request({ invalid: true }), context)).status).toBe(401)
+ })
+ it('validates the contract before the use case', async () => {
+ expect(
+ (await POST(request({ ...body, query: { ...body.query, sql: 'select *' } }), context)).status
+ ).toBe(400)
+ })
+ it('emits a private response', async () => {
+ const response = await POST(request(body), context)
+ expect(response.status).toBe(200)
+ expect(response.headers.get('cache-control')).toBe('private, no-store')
+ expect(await response.json()).toMatchObject({ rows: [{ n: 0 }], truncated: false })
+ })
+})
diff --git a/apps/sim/app/api/table/[tableId]/analytics/route.ts b/apps/sim/app/api/table/[tableId]/analytics/route.ts
new file mode 100644
index 00000000000..3ada82f3fa9
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/analytics/route.ts
@@ -0,0 +1,28 @@
+import { queryTableAnalyticsContract } from '@/lib/api/contracts/table-analytics'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { readTableAnalytics } from '@/lib/table/application/analytics'
+import { tableOperations } from '@/lib/table/application/operations'
+
+export const POST = defineInternalJsonRoute({
+ contract: queryTableAnalyticsContract,
+ auth: internalSessionAuth,
+ operation: tableOperations.analytics,
+ rateLimit: internalRateLimits.user({
+ bucketName: 'table-analytics',
+ config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60_000 },
+ }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ parseOptions: { maxBodyBytes: 64 * 1024 },
+ mapInput: ({ params, body }) => ({
+ tableId: params.tableId,
+ assertedWorkspaceId: body.workspaceId,
+ query: body.query,
+ }),
+ useCase: readTableAnalytics,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts
index c9d66c778e7..c057cc04f08 100644
--- a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts
+++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts
@@ -13,6 +13,6 @@ export const POST = defineInternalJsonRoute({
operation: reconnectPersonalOrganizationAccount.operation,
rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }),
errorPolicy: internalOrchestrationErrorPolicy,
- mapInput: ({ params }) => params,
+ mapInput: ({ params, query }) => ({ ...params, ...query }),
useCase: reconnectPersonalOrganizationAccount,
})
diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts
index 55c405528e1..83b965cadef 100644
--- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts
+++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts
@@ -41,7 +41,10 @@ export const PUT = defineV2JsonRoute({
}),
useCase: updateWorkspaceFileContent,
present: async ({ file }) => ({
- data: { ...(await toV2File(file)), ...workspaceFileRevisionField(file) },
+ data: {
+ ...(await toV2File(file)),
+ ...workspaceFileRevisionField(file),
+ },
}),
})
@@ -78,6 +81,10 @@ export const PATCH = defineV2JsonRoute({
}),
useCase: editWorkspaceFileContent,
present: async ({ file, lineCount }) => ({
- data: { file: await toV2File(file), lineCount, ...workspaceFileRevisionField(file) },
+ data: {
+ file: await toV2File(file),
+ lineCount,
+ ...workspaceFileRevisionField(file),
+ },
}),
})
diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts
index 4f38ee453fb..9a45a12f46f 100644
--- a/apps/sim/app/api/v2/files/route.ts
+++ b/apps/sim/app/api/v2/files/route.ts
@@ -9,6 +9,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file'
+import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision'
import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
@@ -93,5 +94,10 @@ export const POST = defineV2JsonRoute({
exactName: true,
}),
useCase: createWorkspaceFile,
- present: async ({ file }) => ({ data: await toV2File(file) }),
+ present: async ({ file }) => ({
+ data: {
+ ...(await toV2File(file)),
+ ...workspaceFileRevisionField(file),
+ },
+ }),
})
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.test.ts
index e9ee7726393..2deaadc5b43 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.test.ts
@@ -1,3 +1,5 @@
+import { flushMacrotask } from '@sim/testing/helpers/async'
+import { createDeferred } from '@sim/testing/helpers/deferred'
import { createRouteContext } from '@sim/testing/helpers/http'
import { asyncJobsMock, asyncJobsMockFns } from '@sim/testing/mocks/async-jobs.mock'
import {
@@ -551,6 +553,29 @@ describe('workflow execute async route', () => {
expect(executionOptions.snapshot.input).not.toHaveProperty(PRIVATE_SECRET_PROVENANCE_FIELD)
})
+ it('holds a synchronous response until the run log and its cost are finalized', async () => {
+ configureExecutionCaller(EXECUTION_CALLERS[4])
+ const finalizer = createDeferred()
+ loggingSessionMockFns.mockWaitForPostExecution.mockReturnValue(finalizer.promise)
+
+ let responded = false
+ const pending = POST(
+ createInternalProvenanceRequest(),
+ createRouteContext({ id: 'workflow-1' })
+ )
+ void pending.then(() => {
+ responded = true
+ })
+ await vi.waitFor(() => {
+ expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled()
+ })
+ await flushMacrotask()
+ expect(responded).toBe(false)
+
+ finalizer.resolve()
+ expect((await pending).status).toBe(200)
+ })
+
it('queues authenticated workflow input provenance without exposing the private sidecar as input', async () => {
configureExecutionCaller(EXECUTION_CALLERS[4])
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index f40f0d6330a..7df4e6842b9 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -95,6 +95,10 @@ import {
import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/mothership/constants'
import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1'
import { recordDegraded } from '@/lib/mothership/request/metrics'
+import {
+ reportQueuedClientWorkflowTool,
+ reportSettledClientWorkflowTool,
+} from '@/lib/mothership/request/tools/workflow-client-settlement'
import {
ASYNC_WORKFLOW_DEPLOYMENT_ERRORS,
type CopilotWorkflowToolBindingResult,
@@ -509,11 +513,25 @@ async function handleExecutePost(
)
await copilotSettlement
}
+ /** A bound execution reports its own outcome, so a browser that detached never strands the turn. */
const executeBoundWorkflow = async (execute: () => Promise): Promise => {
try {
return await execute()
} finally {
await settleCopilotExecution()
+ if (copilotToolCallId && workflowToolClaimAcquired) {
+ await reportSettledClientWorkflowTool({
+ toolCallId: copilotToolCallId,
+ executionId,
+ workflowId,
+ }).catch((error) => {
+ reqLogger.warn('Could not report settled Copilot workflow execution', {
+ copilotToolCallId,
+ executionId,
+ error: getErrorMessage(error),
+ })
+ })
+ }
}
}
@@ -1297,6 +1315,19 @@ async function handleExecutePost(
trustedInitialResolvedSecretTraceProvenance,
})
executionIdClaimCommitted = asyncResult.retainExecutionClaim
+ if (copilotToolCallId && workflowToolClaimAcquired && asyncResult.retainExecutionClaim) {
+ await reportQueuedClientWorkflowTool({
+ toolCallId: copilotToolCallId,
+ executionId,
+ workflowId,
+ }).catch((error) => {
+ reqLogger.warn('Could not report queued Copilot workflow execution', {
+ copilotToolCallId,
+ executionId,
+ error: getErrorMessage(error),
+ })
+ })
+ }
return asyncResult.response
}
@@ -1636,6 +1667,12 @@ async function handleExecutePost(
reqLogger.error('Failed to cleanup base64 cache', { error })
})
}
+ /**
+ * The sync response is the run's receipt: callers read its log and cost as soon
+ * as it lands. The core finalizes both in the background, so hold the response
+ * until they are durable.
+ */
+ await loggingSession.waitForPostExecution()
}
}
diff --git a/apps/sim/app/api/workspaces/[id]/dashboard/route.ts b/apps/sim/app/api/workspaces/[id]/dashboard/route.ts
new file mode 100644
index 00000000000..7299e0ac01e
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/dashboard/route.ts
@@ -0,0 +1,19 @@
+import { readWorkspaceDashboardContract } from '@/lib/api/contracts/dashboards'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { readWorkspaceDashboard } from '@/lib/dashboards/application/dashboards'
+import { dashboardOperations } from '@/lib/dashboards/application/operations'
+
+export const GET = defineInternalJsonRoute({
+ contract: readWorkspaceDashboardContract,
+ auth: internalSessionAuth,
+ operation: dashboardOperations.read,
+ rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ workspaceId: params.id }),
+ useCase: readWorkspaceDashboard,
+})
diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx
index d9e0ca16172..70cebeb3767 100644
--- a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx
+++ b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx
@@ -7,7 +7,7 @@ import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/comple
describe('credential group OAuth completion', () => {
it.each([undefined, 'failed', 'denied', 'configuration_changed'] as const)(
'publishes %s to only its initiating tab and keeps failures visible',
- (failure) => {
+ async (failure) => {
const postMessage = vi.fn()
const closeChannel = vi.fn()
const names: string[] = []
@@ -27,7 +27,7 @@ describe('credential group OAuth completion', () => {
const root = createRoot(container)
const completionId = '550e8400-e29b-41d4-a716-446655440000'
try {
- act(() =>
+ await act(async () =>
root.render(
)
diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.tsx
index 8f24c0108d4..a20659c828b 100644
--- a/apps/sim/app/credential-groups/complete/completion-handoff.tsx
+++ b/apps/sim/app/credential-groups/complete/completion-handoff.tsx
@@ -1,27 +1,43 @@
'use client'
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
import {
type CredentialGroupOAuthFailure,
credentialGroupOAuthCompletionChannel,
} from '@/lib/credential-groups/oauth-completion'
+import { finishDesktopSourceBrowser } from '@/lib/desktop/source-browser'
interface CredentialGroupCompletionHandoffProps {
completionId: string
failure?: CredentialGroupOAuthFailure
+ returnHref?: string
}
/** Notifies the originating tab even when provider navigation has removed window.opener. */
export function CredentialGroupCompletionHandoff({
completionId,
failure,
+ returnHref,
}: CredentialGroupCompletionHandoffProps) {
+ const started = useRef(false)
useEffect(() => {
- const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId))
- channel.postMessage(failure ?? 'connected')
- channel.close()
- /** Keep the authorization failure visible while the initiating chat shows its retry action. */
- if (!failure) window.close()
- }, [completionId, failure])
+ if (started.current) return
+ started.current = true
+ void finishDesktopSourceBrowser({ kind: 'completion', id: completionId, error: failure }).then(
+ (returned) => {
+ if (returned) return
+ if (typeof BroadcastChannel !== 'undefined') {
+ const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId))
+ channel.postMessage(failure ?? 'connected')
+ channel.close()
+ }
+ /** Keep failures visible when the initiating window is no longer available. */
+ if (!failure) {
+ window.close()
+ if (returnHref && !window.closed) window.location.replace(returnHref)
+ }
+ }
+ )
+ }, [completionId, failure, returnHref])
return null
}
diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx
index 07edac4b434..e9a8243794b 100644
--- a/apps/sim/app/credential-groups/complete/page.tsx
+++ b/apps/sim/app/credential-groups/complete/page.tsx
@@ -5,7 +5,7 @@ import {
CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES,
isCredentialGroupOAuthFailure,
} from '@/lib/credential-groups/oauth-completion'
-import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
+import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths'
import { AuthHeader, AuthShell } from '@/app/(auth)/components'
import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff'
@@ -17,24 +17,43 @@ export const metadata: Metadata = {
export default async function CredentialGroupCompletePage({
searchParams,
}: {
- searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }>
+ searchParams: Promise<{
+ oauth?: string | string[]
+ completionId?: string | string[]
+ organizationId?: string | string[]
+ }>
}) {
- const { oauth, completionId } = await searchParams
+ const { oauth, completionId, organizationId } = await searchParams
const failure =
oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed'
+ const returnHref =
+ typeof organizationId === 'string' && organizationId.length > 0 && organizationId.length <= 128
+ ? organizationRoutes(encodeURIComponent(organizationId)).integrations
+ : undefined
const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined
return (
{typeof completionId === 'string' && isValidUuid(completionId) && (
-
+
)}
- {error && (
+ {(error || returnHref) && (
- Open Sim
+
+ {returnHref ? 'Return to Integrations' : 'Open Sim'}
+
)}
diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
index 8c031917faa..23695eb6b85 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
@@ -20,6 +20,7 @@ import { AuthHeader, SupportFooter } from '@/app/(auth)/components'
import { LogoShell } from '@/app/(landing)/components/logo-shell'
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
+import { SourceCompletion } from '@/app/desktop/connect/source-completion'
import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
@@ -53,6 +54,7 @@ function PageShell({ children }: PageShellProps) {
}
interface UnavailableInvitationProps {
+ token?: string
rateLimited?: boolean
message?: string
recoveryHref?: string
@@ -60,6 +62,7 @@ interface UnavailableInvitationProps {
}
function UnavailableInvitation({
+ token,
rateLimited = false,
message,
recoveryHref = APP_ENTRY_PATH,
@@ -67,6 +70,7 @@ function UnavailableInvitation({
}: UnavailableInvitationProps) {
return (
+ {token && }
+
+ if (limited) return
- const { token } = await params
- if (!token || token.length > 128) return
+ if (!token || token.length > 128) return
const resolvedSearchParams = await searchParams
const callback = new URLSearchParams()
for (const key of ['returnTo', 'optionId']) {
@@ -151,13 +158,14 @@ export default async function CredentialGroupEnrollmentPage({
if (!session.user.emailVerified)
return (
)
const principal = await authenticateCredentialGroupEnrollment(token)
- if (!principal) return
+ if (!principal) return
const returnToSearch = resolvedSearchParams.returnTo === 'search'
const returnToAccounts = resolvedSearchParams.returnTo === 'accounts'
const focused = returnToSearch || returnToAccounts
@@ -176,9 +184,9 @@ export default async function CredentialGroupEnrollmentPage({
return { enrollment: null }
throw error
})
- if (!enrollmentResult) return
+ if (!enrollmentResult) return
if ('enrollmentError' in enrollmentResult)
- return
+ return
const { enrollment } = enrollmentResult
const canReturnToSearch =
returnToSearch &&
@@ -190,7 +198,13 @@ export default async function CredentialGroupEnrollmentPage({
: 'Open knowledge bases'
: 'Open Sim'
if (!enrollment)
- return
+ return (
+
+ )
const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth')
const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected')
@@ -207,7 +221,13 @@ export default async function CredentialGroupEnrollmentPage({
? activeOptions.find((option) => option.id === focusedOptionId)
: undefined
if (focused && !focusedOption)
- return
+ return (
+
+ )
const visibleOptions = focusedOption ? [focusedOption] : activeOptions
const focusedConnected =
focusedOption?.connections[0]?.status === 'connected' &&
@@ -235,6 +255,15 @@ export default async function CredentialGroupEnrollmentPage({
: null
return (
+ {(oauthMessage ||
+ connectedOption?.connections.some((connection) => connection.status === 'connected') ||
+ connectedMcpServer?.connection?.status === 'connected') && (
+
+ )}
{notification && (
diff --git a/apps/sim/app/credential-groups/slack-complete/page.tsx b/apps/sim/app/credential-groups/slack-complete/page.tsx
new file mode 100644
index 00000000000..7dc492e13fb
--- /dev/null
+++ b/apps/sim/app/credential-groups/slack-complete/page.tsx
@@ -0,0 +1,56 @@
+import { ChipLink } from '@sim/emcn'
+import type { Metadata } from 'next'
+import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths'
+import { SlackCompletion } from '@/app/credential-groups/slack-complete/slack-completion'
+import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
+
+export const metadata: Metadata = {
+ title: 'Slack connection',
+ robots: { index: false, follow: false },
+}
+interface SlackCompletePageProps {
+ searchParams: Promise>
+}
+
+export default async function SlackCompletePage({ searchParams }: SlackCompletePageProps) {
+ const params = await searchParams
+ const scalar = (key: string) =>
+ typeof params[key] === 'string' && params[key].length <= 512 ? params[key] : undefined
+ const ok = params.ok === 'true'
+ const signInRequired = !ok && params.reason === 'signin_required'
+ const mode = params.mode === 'managed' ? 'managed' : 'search'
+ const organizationId = scalar('organizationId')
+ return (
+
+
+
+ {signInRequired ? 'Sign in to Sim' : 'Return to Sim'}
+
+
+ )
+}
diff --git a/apps/sim/app/credential-groups/slack-complete/slack-completion.tsx b/apps/sim/app/credential-groups/slack-complete/slack-completion.tsx
new file mode 100644
index 00000000000..bc64579622c
--- /dev/null
+++ b/apps/sim/app/credential-groups/slack-complete/slack-completion.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { finishDesktopSourceBrowser } from '@/lib/desktop/source-browser'
+import { organizationRoutes } from '@/lib/navigation/paths'
+
+interface SlackCompletionProps {
+ organizationId?: string
+ mode: 'managed' | 'search'
+ ok: boolean
+ state?: string
+ reason?: string
+ credentialGroupId?: string
+ slackBotCredentialId?: string
+}
+
+export function SlackCompletion({
+ organizationId,
+ mode,
+ ok,
+ state,
+ reason,
+ credentialGroupId,
+ slackBotCredentialId,
+}: SlackCompletionProps) {
+ const started = useRef(false)
+ useEffect(() => {
+ if (started.current || !state) return
+ started.current = true
+ void finishDesktopSourceBrowser({
+ kind: mode === 'managed' ? 'slack-managed-users' : 'slack-search',
+ id: state,
+ ...(ok ? {} : { error: reason ?? 'failed' }),
+ }).then((returned) => {
+ if (returned) return
+ if (mode === 'search') {
+ if (ok && organizationId) {
+ const url = new URL(
+ organizationRoutes(organizationId).settingsSection('search-slack'),
+ window.location.origin
+ )
+ url.searchParams.set('slackSetup', 'complete')
+ window.location.replace(url.href)
+ }
+ return
+ }
+ const channel = new BroadcastChannel('slack-managed-users')
+ channel.postMessage({
+ type: 'slack-managed-users',
+ ok,
+ state,
+ reason,
+ credentialGroupId,
+ slackBotCredentialId,
+ })
+ channel.close()
+ if (ok) window.close()
+ })
+ }, [organizationId, mode, ok, state, reason, credentialGroupId, slackBotCredentialId])
+ return null
+}
diff --git a/apps/sim/app/desktop/connect/complete/page.tsx b/apps/sim/app/desktop/connect/complete/page.tsx
index b1369324867..55b6cbb6277 100644
--- a/apps/sim/app/desktop/connect/complete/page.tsx
+++ b/apps/sim/app/desktop/connect/complete/page.tsx
@@ -2,7 +2,11 @@ import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { isValidHandoffState, parseLoopbackPort } from '@/app/desktop/auth/validation'
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
-import { buildConnectLoopbackUrl, sanitizeOAuthErrorSlug } from '@/app/desktop/connect/validation'
+import {
+ buildConnectLoopbackUrl,
+ isValidOpaqueId,
+ sanitizeOAuthErrorSlug,
+} from '@/app/desktop/connect/validation'
export const metadata: Metadata = {
title: 'Returning to Sim',
@@ -44,5 +48,12 @@ export default async function ConnectCompletePage({ searchParams }: ConnectCompl
// failure must never read as success — take the first code.
const rawError = Array.isArray(params.error) ? params.error[0] : params.error
const error = sanitizeOAuthErrorSlug(rawError)
- redirect(buildConnectLoopbackUrl(state, port, error ?? undefined))
+ redirect(
+ buildConnectLoopbackUrl(
+ state,
+ port,
+ error ?? undefined,
+ isValidOpaqueId(params.credentialId) ? params.credentialId : undefined
+ )
+ )
}
diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx
index 2a7edb3aa9f..510e1d5b35e 100644
--- a/apps/sim/app/desktop/connect/page.tsx
+++ b/apps/sim/app/desktop/connect/page.tsx
@@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { isValidHandoffState, parseLoopbackPort } from '@/app/desktop/auth/validation'
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
import { ConnectLauncher } from '@/app/desktop/connect/connect-launcher'
+import { SourceConnectLauncher } from '@/app/desktop/connect/source-connect-launcher'
import { SwitchAccount } from '@/app/desktop/connect/switch-account'
import {
buildConnectCompletePath,
@@ -63,12 +64,26 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
const credentialId = isValidOpaqueId(params.credentialId) ? params.credentialId : undefined
const draftId = isValidOpaqueId(params.draftId) ? params.draftId : undefined
const expectedUserId = isValidOpaqueId(params.user) ? params.user : undefined
+ const sourceRequestId =
+ typeof params.sourceRequestId === 'string' && /^[A-Za-z0-9_-]{32}$/.test(params.sourceRequestId)
+ ? params.sourceRequestId
+ : undefined
+ const invalidSource =
+ params.sourceRequestId !== undefined &&
+ (!sourceRequestId ||
+ providerId !== 'source' ||
+ !expectedUserId ||
+ workspaceId ||
+ credentialId ||
+ draftId)
const hasInvalidDraftId = params.draftId !== undefined && draftId === undefined
if (
!isValidOAuthProviderId(providerId) ||
!isValidHandoffState(state) ||
port === null ||
hasInvalidDraftId ||
+ invalidSource ||
+ (providerId === 'source' && !sourceRequestId) ||
(workspaceId !== undefined && draftId !== undefined)
) {
return
@@ -89,6 +104,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
credentialId,
draftId,
user: expectedUserId,
+ sourceRequestId,
})
)}`
)
@@ -111,6 +127,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
credentialId,
draftId,
user: expectedUserId,
+ sourceRequestId,
})}
/>
@@ -122,6 +139,9 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
// draft — including reconnect rebinding when a credentialId rides along.
// Modal-initiated connects have no workspaceId here (the desktop app already
// created the draft) and use the plain link flow below.
+ if (sourceRequestId)
+ return
+
if (workspaceId) {
const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl())
authorize.searchParams.set('providerId', providerId)
diff --git a/apps/sim/app/desktop/connect/source-completion.tsx b/apps/sim/app/desktop/connect/source-completion.tsx
new file mode 100644
index 00000000000..b16ab6e6eb5
--- /dev/null
+++ b/apps/sim/app/desktop/connect/source-completion.tsx
@@ -0,0 +1,17 @@
+'use client'
+
+import { useEffect } from 'react'
+import {
+ type DesktopSourceCompletion,
+ finishDesktopSourceBrowser,
+} from '@/lib/desktop/source-browser'
+
+interface SourceCompletionProps extends DesktopSourceCompletion {}
+
+/** Mounted by terminal pages after their existing server-side authorization checks. */
+export function SourceCompletion({ kind, id, error }: SourceCompletionProps) {
+ useEffect(() => {
+ void finishDesktopSourceBrowser({ kind, id, error })
+ }, [kind, id, error])
+ return null
+}
diff --git a/apps/sim/app/desktop/connect/source-connect-launcher.tsx b/apps/sim/app/desktop/connect/source-connect-launcher.tsx
new file mode 100644
index 00000000000..b12cd7ad3ff
--- /dev/null
+++ b/apps/sim/app/desktop/connect/source-connect-launcher.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { Chip } from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { startDesktopSourceBrowser } from '@/lib/desktop/source-browser'
+import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
+import { buildConnectCompletePath } from '@/app/desktop/connect/validation'
+
+interface SourceConnectLauncherProps {
+ requestId: string
+ state: string
+ port: number
+}
+
+export function SourceConnectLauncher({ requestId, state, port }: SourceConnectLauncherProps) {
+ const started = useRef(false)
+ const [error, setError] = useState(null)
+ useEffect(() => {
+ if (started.current) return
+ started.current = true
+ void startDesktopSourceBrowser(requestId, state, port).catch((failure) => {
+ setError(getErrorMessage(failure, 'Could not start this connection. Try again from Sim.'))
+ })
+ }, [requestId, state, port])
+ return (
+
+ {error && (
+
+ window.location.replace(
+ `${buildConnectCompletePath(state, port)}&error=connection_failed`
+ )
+ }
+ >
+ Return to Sim
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/desktop/connect/validation.ts b/apps/sim/app/desktop/connect/validation.ts
index 4a70467707e..891283719ed 100644
--- a/apps/sim/app/desktop/connect/validation.ts
+++ b/apps/sim/app/desktop/connect/validation.ts
@@ -36,6 +36,7 @@ export function isValidOpaqueId(value: unknown): value is string {
/** Optional connect scope forwarded from the desktop app's credential chips. */
export interface ConnectScope {
+ sourceRequestId?: string
workspaceId?: string
credentialId?: string
draftId?: string
@@ -54,6 +55,7 @@ export function buildDesktopConnectPath(
scope: ConnectScope = {}
): string {
const params = new URLSearchParams({ provider: providerId, state, port: String(port) })
+ if (scope.sourceRequestId) params.set('sourceRequestId', scope.sourceRequestId)
if (scope.workspaceId) params.set('workspaceId', scope.workspaceId)
if (scope.credentialId) params.set('credentialId', scope.credentialId)
if (scope.draftId) params.set('draftId', scope.draftId)
@@ -76,8 +78,14 @@ export function buildConnectCompletePath(state: string, port: number, draftId?:
* §7.3 — the `127.0.0.1` IP literal, mirroring the login handoff). A present
* `error` marks the flow failed; the app surfaces it as a toast.
*/
-export function buildConnectLoopbackUrl(state: string, port: number, error?: string): string {
+export function buildConnectLoopbackUrl(
+ state: string,
+ port: number,
+ error?: string,
+ credentialId?: string
+): string {
const params = new URLSearchParams({ state })
+ if (credentialId) params.set('credentialId', credentialId)
if (error) {
params.set('error', error)
}
diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx
index 71a5c564492..8839f44f4a7 100644
--- a/apps/sim/app/f/[token]/public-file-email-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx
@@ -6,8 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { useRouter } from 'next/navigation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
-import { AuthSubmitButton } from '@/app/(auth)/components'
-import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
+import { AuthSubmitButton, AuthTextLink } from '@/app/(auth)/components'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries/public-shares'
@@ -176,28 +175,23 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
Resend in {countdown}s
) : (
-
+
Resend
-
+
)}
- {
setSent(false)
setOtp('')
setError(null)
}}
- className={AUTH_TEXT_LINK}
>
Change email
-
+
diff --git a/apps/sim/app/knowledge/github/setup/setup.tsx b/apps/sim/app/knowledge/github/setup/setup.tsx
index 57d40e49160..f47cda034a1 100644
--- a/apps/sim/app/knowledge/github/setup/setup.tsx
+++ b/apps/sim/app/knowledge/github/setup/setup.tsx
@@ -44,7 +44,13 @@ export function GitHubSetup({ scope }: GitHubSetupProps) {
? 'This connection attempt expired. Close this window and connect GitHub again from Sim.'
: null)
- if (failure) return
+ if (failure)
+ return (
+ <>
+
+
+ >
+ )
if (result?.status === 'completed') {
return (
<>
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx
index 6ddf920ea42..defa0366828 100644
--- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx
@@ -234,6 +234,7 @@ async function render(
{
- window.location.assign(authorizationUrl ?? invitationLink)
const onError = (error: Error) => toast.error(error.message)
const description = account
? `${accounts.map((entry) => entry.displayName).join(', ')} · ${account.status === 'needs_reauth' ? 'Reconnect required' : 'Connected'}`
@@ -74,9 +71,7 @@ export function GitHubMemberIntegration({
- reconnect.mutate(account.credentialId, { onSuccess: navigate, onError })
- }
+ onClick={() => reconnect.mutate(account.credentialId, { onError })}
>
Reconnect
@@ -84,12 +79,7 @@ export function GitHubMemberIntegration({
- connect.mutate(
- { organizationId, optionId: option.id },
- { onSuccess: navigate, onError }
- )
- }
+ onClick={() => connect.mutate({ organizationId, optionId: option.id }, { onError })}
>
Connect
diff --git a/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts b/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts
index e6d0376bf55..76faa926cc8 100644
--- a/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts
+++ b/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts
@@ -3,7 +3,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
-import { type QueryKey, useQueryClient } from '@tanstack/react-query'
+import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query'
+import type { DesktopSourceRequest } from '@/lib/api/contracts/desktop-source-connect'
import {
type ResourceScope,
resourceScopeFields,
@@ -14,6 +15,8 @@ import {
credentialGroupOAuthCompletionChannel,
isCredentialGroupOAuthFailure,
} from '@/lib/credential-groups/oauth-completion'
+import { isDesktopApp } from '@/lib/desktop'
+import { connectDesktopSource } from '@/lib/desktop/source-connect'
import type { SearchConnector } from '@/lib/sim-search/connectors'
import {
useConnectSimSearchConnector,
@@ -96,6 +99,22 @@ export function useMemberEnrollment({
>()
)
const queryClient = useQueryClient()
+ const nativeAbort = useRef(null)
+ useEffect(() => () => nativeAbort.current?.abort(), [])
+ const nativeConnection = useMutation({
+ mutationFn: async (request: DesktopSourceRequest) => {
+ nativeAbort.current?.abort()
+ const controller = new AbortController()
+ nativeAbort.current = controller
+ return connectDesktopSource(request, controller.signal)
+ },
+ onSettled: () =>
+ Promise.all(
+ membershipQueryKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey }))
+ ),
+ onError: (error) => onConnectionError?.(error.message),
+ onSuccess: () => setSetupConnector(null),
+ })
const enrollment = useStartConnectorMemberEnrollment()
const sourceConnection = useConnectSimSearchConnector()
const [awaitingSince, setAwaitingSince] = useState>(
@@ -265,7 +284,15 @@ export function useMemberEnrollment({
})
}
- const connect = (knowledgeBaseId: string, connectorId: string) =>
+ const connect = (knowledgeBaseId: string, connectorId: string) => {
+ if (isDesktopApp()) {
+ nativeConnection.mutate({
+ kind: 'member-enrollment',
+ params: { id: knowledgeBaseId, connectorId },
+ ...(directOAuth ? { completionId: generateId() } : {}),
+ })
+ return
+ }
openEnrollment(`connector:${connectorId}`, ({ onSuccess, onError, oauthCompletionId }) => {
enrollment.mutate(
{ knowledgeBaseId, connectorId, ...(oauthCompletionId ? { oauthCompletionId } : {}) },
@@ -279,6 +306,7 @@ export function useMemberEnrollment({
}
)
})
+ }
/**
* Connects a Sim Search source: its per-member connector exists afterwards,
@@ -292,6 +320,14 @@ export function useMemberEnrollment({
) => {
const scope =
typeof owner === 'string' ? { kind: 'workspace' as const, workspaceId: owner } : owner
+ if (isDesktopApp()) {
+ nativeConnection.mutate({
+ kind: 'search-source',
+ body: { ...resourceScopeFields(scope), connectorType, sourceConfig },
+ ...(directOAuth ? { completionId: generateId() } : {}),
+ })
+ return
+ }
const configKey = JSON.stringify(
Object.entries(sourceConfig ?? {}).sort(([left], [right]) => left.localeCompare(right))
)
@@ -335,9 +371,12 @@ export function useMemberEnrollment({
}
const isAwaiting = (connectorId: string) =>
- awaitingSince.has(connectorId) &&
- (Boolean(awaitingSince.get(connectorId)?.oauthCompletionId) ||
- !connectedConnectorIds.has(connectorId))
+ (nativeConnection.isPending &&
+ nativeConnection.variables?.kind === 'member-enrollment' &&
+ nativeConnection.variables.params.connectorId === connectorId) ||
+ (awaitingSince.has(connectorId) &&
+ (Boolean(awaitingSince.get(connectorId)?.oauthCompletionId) ||
+ !connectedConnectorIds.has(connectorId)))
/**
* Whether a Sim Search source is awaited by the connect that created its
@@ -345,6 +384,9 @@ export function useMemberEnrollment({
* the source cannot be looked up by connector id yet.
*/
const isAwaitingSource = (connectorType: string) =>
+ (nativeConnection.isPending &&
+ nativeConnection.variables?.kind === 'search-source' &&
+ nativeConnection.variables.body.connectorType === connectorType) ||
[...awaitingSince].some(
([id, awaiting]) =>
awaiting.connectorType === connectorType &&
@@ -359,10 +401,16 @@ export function useMemberEnrollment({
connectSource,
connectSearchSource,
setupConnector,
- closeSetup: () => setSetupConnector(null),
+ closeSetup: () => {
+ nativeAbort.current?.abort()
+ nativeAbort.current = null
+ setSetupConnector(null)
+ },
isAwaiting,
isAwaitingSource,
- isPending: enrollment.isPending || sourceConnection.isPending,
- error: popupBlocked ? POPUP_BLOCKED_MESSAGE : (oauthError ?? latest.error?.message ?? null),
+ isPending: nativeConnection.isPending || enrollment.isPending || sourceConnection.isPending,
+ error: popupBlocked
+ ? POPUP_BLOCKED_MESSAGE
+ : (nativeConnection.error?.message ?? oauthError ?? latest.error?.message ?? null),
}
}
diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx
index 2e4afdf955d..243d053ed17 100644
--- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx
@@ -22,7 +22,6 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors'
-import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts'
import type { SearchConnector } from '@/lib/sim-search/connectors'
const mocks = vi.hoisted(() => ({
@@ -287,22 +286,6 @@ function menuItem(label: string) {
)!
}
-function expectConnectionRedirect(
- onSuccess: (response: OrganizationAccountConnectionResponse) => void,
- authorizationUrl?: string
-) {
- const invitationLink = 'https://sim.test/credential-groups/enroll/fixture-token'
- const assign = vi.fn()
- const browserWindow = window
- vi.stubGlobal('window', { location: { assign } })
- try {
- onSuccess({ invitationLink, ...(authorizationUrl ? { authorizationUrl } : {}) })
- expect(assign).toHaveBeenCalledExactlyOnceWith(authorizationUrl ?? invitationLink)
- } finally {
- vi.stubGlobal('window', browserWindow)
- }
-}
-
describe('GitHub member account inventory', () => {
const githubAccount = {
credentialId: 'github-account',
@@ -346,10 +329,7 @@ describe('GitHub member account inventory', () => {
})
})
- it.each([
- undefined,
- 'https://sim.test/api/credential-groups/enroll/fixture-token/oauth/github-option?returnTo=search',
- ])('connects once through the account operation with compatible redirect %s', async (url) => {
+ it('connects once through the account operation', async () => {
await render()
expect(buttons('Connect')).toHaveLength(1)
expect(container.textContent).toContain('Connect once')
@@ -358,7 +338,6 @@ describe('GitHub member account inventory', () => {
{ organizationId: scope.organizationId, optionId: 'github-option' },
expect.any(Object)
)
- expectConnectionRedirect(mocks.connectOrganizationAccount.mock.calls[0][1].onSuccess, url)
expect(mockUseSearchSources).not.toHaveBeenCalled()
expect(mocks.connect).not.toHaveBeenCalled()
expect(mocks.connectSearchSource).not.toHaveBeenCalled()
@@ -430,10 +409,7 @@ describe('GitHub member account inventory', () => {
}
)
- it.each([
- undefined,
- 'https://sim.test/api/credential-groups/enroll/fixture-token/oauth/github-option?returnTo=accounts',
- ])('allows personal reauthorization while Search is disabled with redirect %s', async (url) => {
+ it('allows personal reauthorization while Search is disabled', async () => {
mockUseSearchSourceOverview.mockReturnValue({ data: { providers: [] }, isPending: false })
mocks.integrations.mockReturnValue({
data: [{ connectorType: 'github', approved: false }],
@@ -457,7 +433,6 @@ describe('GitHub member account inventory', () => {
'github-account',
expect.any(Object)
)
- expectConnectionRedirect(mocks.reconnectOrganizationAccount.mock.calls[0][1].onSuccess, url)
expect(mocks.connect).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx
index ea564bebf92..fc8a37a4ea5 100644
--- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx
@@ -1,7 +1,6 @@
'use client'
import { Chip, toast } from '@sim/emcn'
-import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts'
import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema'
import { liveSearchProviderForCredential } from '@/lib/sim-search/live/provider-catalog'
import {
@@ -36,8 +35,6 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
const secrets = useOrganizationSecretSource(organizationId)
const connect = useConnectOrganizationAccount()
const reconnect = useReconnectPersonalOrganizationAccount()
- const navigate = (result: OrganizationAccountConnectionResponse) =>
- window.location.assign(result.authorizationUrl ?? result.invitationLink)
const onError = (error: Error) => toast.error(error.message)
const error = inventory.error ?? policies.error ?? secrets.error
if (error)
@@ -74,9 +71,10 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
)
const available = LIVE_SEARCH_SOURCE_TYPES.filter(
([provider]) =>
+ (approvals.get(provider)?.available !== false || accountsForProvider(provider).length > 0) &&
LIVE_SEARCH_SCOPE_FIELDS[provider] &&
- (provider !== 'hubspot' ||
- data.availableMcpConnectors.includes('hubspot') ||
+ ((provider !== 'hubspot' && provider !== 'zoom') ||
+ data.availableMcpConnectors.includes(provider) ||
mcpAccounts(provider).length > 0) &&
(approvals.get(provider)?.approved ||
data.viewerAccounts?.some(
@@ -130,24 +128,29 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
: undefined
const accounts = accountsForProvider(provider)
const ready =
+ approval?.available !== false &&
group?.status === 'active' &&
Boolean(option || server) &&
approved &&
(!option || option.configurationStatus === 'ready') &&
- (provider !== 'hubspot' || data.availableMcpConnectors.includes('hubspot'))
+ ((provider !== 'hubspot' && provider !== 'zoom') ||
+ data.availableMcpConnectors.includes(provider))
const scope =
approval?.policy?.accessMode === 'service_account'
? 'Selected resources you can access'
: 'All accessible content'
- const state = !approved
- ? 'Disabled by your organization'
- : group && group.status !== 'active'
- ? 'Connections are paused by your organization'
- : !ready
- ? 'Not configured'
- : accounts.length
- ? scope
- : undefined
+ const state =
+ approval?.available === false
+ ? 'Currently unavailable'
+ : !approved
+ ? 'Disabled by your organization'
+ : group && group.status !== 'active'
+ ? 'Connections are paused by your organization'
+ : !ready
+ ? 'Not configured'
+ : accounts.length
+ ? scope
+ : undefined
const description = [
accounts
.map(
@@ -179,9 +182,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
- reconnect.mutate(account.credentialId, { onSuccess: navigate, onError })
- }
+ onClick={() => reconnect.mutate(account.credentialId, { onError })}
>
Reconnect{accounts.length > 1 ? ` ${account.displayName}` : ''}
@@ -196,7 +197,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
organizationId,
...(server ? { mcpServerId: server.id } : { optionId: option!.id }),
},
- { onSuccess: navigate, onError }
+ { onError }
)
}
>
diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
index 5658ec0818e..3ad21c07b64 100644
--- a/apps/sim/app/o/[organizationId]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
+import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
@@ -55,23 +56,26 @@ export default async function OrganizationLayout({
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
redirect(WORKSPACE_SETTINGS_PATH)
- const [, tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled] = await Promise.all([
- prefetchOrganizationSidebar(
- queryClient,
- organizationId,
- { kind: 'session', userId: session.user.id, sessionId: session.session.id },
- getActiveOrganizationId(session)
- ),
- isTableRowTtlEnabled(),
- isMothershipModelSelectorEnabled(),
- isPlanModeEnabled(),
- ])
+ const [, tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] =
+ await Promise.all([
+ prefetchOrganizationSidebar(
+ queryClient,
+ organizationId,
+ { kind: 'session', userId: session.user.id, sessionId: session.session.id },
+ getActiveOrganizationId(session)
+ ),
+ isTableRowTtlEnabled(),
+ isMothershipModelSelectorEnabled(),
+ isPlanModeEnabled(),
+ isDashboardsEnabled(organizationId),
+ ])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
return (
LIVE_SEARCH_SCOPE_FIELDS[type] && !added.some((row) => row.connectorType === type)
+ ([type]) =>
+ (type !== 'zoom' ||
+ policies.data?.some((row) => row.connectorType === type && row.available !== false)) &&
+ LIVE_SEARCH_SCOPE_FIELDS[type] &&
+ !added.some((row) => row.connectorType === type)
).map(([type, meta]) => ({
type,
meta,
availabilityStatus:
- type !== 'hubspot' || accounts.isSuccess
+ (type !== 'hubspot' && type !== 'zoom') || accounts.isSuccess
? undefined
: accounts.isError
? ('error' as const)
@@ -160,6 +164,7 @@ export function LiveSearchSettings() {
const mcpProvider = liveSearchMcpConnector(type)
const group = accounts.data?.credentialGroup
const needsMemberSetup =
+ integration.available !== false &&
accounts.data &&
(memberProvider || mcpProvider) &&
(group?.status !== 'active' ||
@@ -188,7 +193,7 @@ export function LiveSearchSettings() {
iconVariant='custom'
icon={ }
title={meta.name}
- description={scope}
+ description={integration.available === false ? 'Currently unavailable' : scope}
trailing={
{serviceAccount && (
diff --git a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx
index 1bcec157d2b..09641a3295c 100644
--- a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx
@@ -16,6 +16,7 @@ const CLIENTS = [
{ value: 'codex', label: 'Codex' },
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'cursor', label: 'Cursor' },
+ { value: 'devin', label: 'Devin' },
{ value: 'other', label: 'Other' },
] as const
@@ -66,11 +67,13 @@ export function SearchMcpConnection({ endpoint }: SearchMcpConnectionProps) {
hint={
client === 'claude'
? 'In Claude web or Desktop, add a custom connector with this URL, then sign in to Sim. On Team or Enterprise, an owner adds the connector first.'
- : client === 'other'
- ? 'Add this URL in an app that supports remote MCP with OAuth. Choose Streamable HTTP if asked, then sign in to Sim.'
- : client === 'claude-code'
- ? 'Run this command, then open /mcp in Claude Code to connect and sign in to Sim.'
- : 'Run this command and sign in to Sim in the browser. To reconnect, run codex mcp login sim-search.'
+ : client === 'devin'
+ ? 'In Devin, open Customize → MCPs and add a custom MCP with this URL. Choose HTTP, OAuth, and Personal access, then select Connect and sign in to Sim.'
+ : client === 'other'
+ ? 'Add this URL in an app that supports remote MCP with OAuth. Choose Streamable HTTP if asked, then sign in to Sim.'
+ : client === 'claude-code'
+ ? 'Run this command, then open /mcp in Claude Code to connect and sign in to Sim.'
+ : 'Run this command and sign in to Sim in the browser. To reconnect, run codex mcp login sim-search.'
}
/>
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/dashboards/layout.tsx b/apps/sim/app/workspace/[workspaceId]/dashboards/layout.tsx
new file mode 100644
index 00000000000..ec6ae225b30
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/dashboards/layout.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from 'react'
+import { DashboardFeatureGate } from '@/components/dashboards/dashboard-feature-gate'
+
+interface DashboardLayoutProps {
+ children: ReactNode
+}
+
+export default function DashboardLayout({ children }: DashboardLayoutProps) {
+ return
{children}
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/dashboards/loading.tsx b/apps/sim/app/workspace/[workspaceId]/dashboards/loading.tsx
new file mode 100644
index 00000000000..6c77381d69f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/dashboards/loading.tsx
@@ -0,0 +1,10 @@
+import { DashboardLoading } from '@/components/dashboards/dashboard-loading'
+import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
+
+export default function DashboardsLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/dashboards/page.tsx b/apps/sim/app/workspace/[workspaceId]/dashboards/page.tsx
new file mode 100644
index 00000000000..bcfceaea6ea
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/dashboards/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react'
+import type { Metadata } from 'next'
+import { DashboardResource } from '@/components/dashboards/dashboard-resource'
+import DashboardsLoading from '@/app/workspace/[workspaceId]/dashboards/loading'
+
+export const metadata: Metadata = { title: 'Dashboard', robots: { index: false } }
+
+interface DashboardsPageProps {
+ params: Promise<{ workspaceId: string }>
+}
+
+export default async function DashboardsPage({ params }: DashboardsPageProps) {
+ const { workspaceId } = await params
+ return (
+
}>
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx
index c99164e58e0..fcc727388a5 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx
@@ -1,87 +1,32 @@
'use client'
-import { memo, useEffect, useMemo, useRef, useState } from 'react'
+import { useMemo } from 'react'
+import { cn } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
-import type { EChartsOption } from 'echarts'
-import { useTheme } from 'next-themes'
-import { buildChartRenderOption } from '@/lib/charts/option'
+import { toRecord } from '@sim/utils/object'
+import { EChartsView } from '@/components/charts/echarts-view'
+import { buildChartRenderOption, horizontalBarChartHeight } from '@/lib/charts/option'
import {
CHART_ROWS_DEFAULT,
CHART_ROWS_MAX,
- type ChartSpec,
mapRowsToColumnNames,
parseChartSpec,
shapeTableRows,
} from '@/lib/charts/spec'
+import { PreviewLoadingFrame } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { useTable, useTableRowsSample } from '@/hooks/queries/tables'
-import { PreviewLoadingFrame } from './preview-shared'
-function buildOption(spec: ChartSpec, rows: Array
> | null): EChartsOption {
- return buildChartRenderOption({ title: spec.title, option: spec.option, rows }) as EChartsOption
-}
-
-function ChartErrorCard({ message, content }: { message: string; content: string }) {
- return (
-
-
- chart
- {message}
-
-
-
- )
-}
-
-function ChartErrorPanel({ message, content }: { message: string; content: string }) {
- return (
-
-
-
- )
-}
-
-type EChartsModule = typeof import('echarts')
+const CHART_HEADER_HEIGHT = 24
-/**
- * Renders a `.chart` document with ECharts, lazy-loading the (heavy) library
- * on first use. Static sources render inline rows; table sources read the
- * table live through React Query, so the chart reflects the table's current
- * data every time it is opened.
- */
-export const ChartPreview = memo(function ChartPreview({
- content,
- workspaceId,
- isStreaming = false,
-}: {
+interface ChartPreviewProps {
content: string
workspaceId: string
isStreaming?: boolean
-}) {
- const containerRef = useRef(null)
- const [echartsLib, setEchartsLib] = useState(null)
- const [loadError, setLoadError] = useState(null)
- const [renderError, setRenderError] = useState(null)
-
- useEffect(() => {
- let active = true
- import('echarts')
- .then((mod) => {
- if (active) setEchartsLib(mod)
- })
- .catch((e) => {
- if (active) setLoadError(getErrorMessage(e, 'failed to load the chart renderer'))
- })
- return () => {
- active = false
- }
- }, [])
+}
+/** Existing chart documents retain their source semantics and share the dashboard canvas theme. */
+export function ChartPreview({ content, workspaceId, isStreaming = false }: ChartPreviewProps) {
const { spec, error: parseError } = useMemo(() => parseChartSpec(content), [content])
-
const tableSource = spec?.source?.type === 'table' ? spec.source : null
const rowsQuery = useTableRowsSample({
workspaceId,
@@ -92,7 +37,6 @@ export const ChartPreview = memo(function ChartPreview({
enabled: Boolean(tableSource),
})
const tableQuery = useTable(tableSource ? workspaceId : undefined, tableSource?.tableId)
-
const rows = useMemo(() => {
if (!spec) return null
if (spec.source?.type === 'static') return spec.source.rows ?? null
@@ -103,76 +47,47 @@ export const ChartPreview = memo(function ChartPreview({
return shapeTableRows(mapRowsToColumnNames(fetched, columns), tableSource)
}, [spec, tableSource, rowsQuery.data, tableQuery.data])
- const option = useMemo(() => (spec ? buildOption(spec, rows) : null), [spec, rows])
- /** Stable identity for the effect below — option is a fresh object per memo. */
- const optionKey = useMemo(() => (option ? JSON.stringify(option) : ''), [option])
-
- const { resolvedTheme } = useTheme()
-
- useEffect(() => {
- setRenderError(null)
- const el = containerRef.current
- if (!el || !echartsLib || !option) return
- const chart = echartsLib.init(el, resolvedTheme === 'dark' ? 'dark' : undefined)
- try {
- chart.setOption(option)
- } catch (e) {
- setRenderError(getErrorMessage(e, 'invalid ECharts option'))
- chart.dispose()
- return
- }
- const resizeObserver = new ResizeObserver(() => chart.resize())
- resizeObserver.observe(el)
- return () => {
- resizeObserver.disconnect()
- chart.dispose()
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [echartsLib, optionKey, resolvedTheme])
-
- if (parseError) {
- // A file the agent is still writing is expected to be truncated JSON.
- if (isStreaming) return
- return
- }
- if (loadError) return
- if (tableSource && rowsQuery.isError) {
+ if (parseError && isStreaming) return
+ const error =
+ parseError ??
+ (tableSource && (rowsQuery.isError || tableQuery.isError)
+ ? getErrorMessage(rowsQuery.error ?? tableQuery.error, 'Failed to read table')
+ : null)
+ if (error)
return (
-
- )
- }
-
- if (tableSource && tableQuery.isError) {
- return (
-
+
+
+ {error}
+
+
+ {content}
+
+
)
- }
-
- const waitingOnRows = Boolean(tableSource) && rows === null
- // Width-driven aspect box, not full-bleed: a chart stretched to the whole
- // panel height is unreadable in a tall resource pane. ECharts follows the
- // box through the ResizeObserver above.
- //
- // A render error (setOption threw) HIDES the chart box rather than
- // unmounting it: the render effect only re-runs when the option changes,
- // and it needs the container mounted at that moment to re-initialize —
- // an unmounted container would leave the fixed chart blank until a
- // second edit.
+ if (!spec || (tableSource && rows === null))
+ return
+ const yAxis = toRecord(
+ Array.isArray(spec.option.yAxis) ? spec.option.yAxis[0] : spec.option.yAxis
+ )
+ const categories = Array.isArray(yAxis.data) ? yAxis.data.length : (rows?.length ?? 0)
+ const barHeight = horizontalBarChartHeight(spec.option, categories)
+ /** Title and legend share one chrome row inside this canvas, unlike dashboard panels. */
+ const chromeHeight = spec.title || spec.option.legend ? CHART_HEADER_HEIGHT : 0
return (
- {renderError !== null &&
}
-
- {(!echartsLib || waitingOnRows) && (
-
+
+ style={barHeight === null ? undefined : { height: barHeight + chromeHeight }}
+ >
+
)
-})
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts b/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts
index 83da66a883e..82e9a0bd107 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts
@@ -3,6 +3,7 @@
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
import { useQueryClient } from '@tanstack/react-query'
import { useWorkspaceInvalidationRoom } from '@/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room'
+import { dashboardKeys } from '@/hooks/queries/dashboards'
import {
invalidateWorkspaceFileBrowsers,
WORKSPACE_FILE_BROWSER_INVALIDATION_KEY,
@@ -18,7 +19,10 @@ export function useWorkspaceFilesRoom(workspaceId: string): void {
useWorkspaceInvalidationRoom(
workspaceId,
ROOM_TYPES.WORKSPACE_FILES,
- () => invalidateWorkspaceFileBrowsers(queryClient, workspaceId),
+ () => {
+ invalidateWorkspaceFileBrowsers(queryClient, workspaceId)
+ void queryClient.invalidateQueries({ queryKey: dashboardKeys.workspace(workspaceId) })
+ },
WORKSPACE_FILE_BROWSER_INVALIDATION_KEY
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx
index 626eefe6b1d..80ca0db6ef1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import {
+ Dashboard,
Database,
Folder as FolderIcon,
Globe,
@@ -79,6 +80,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record ,
},
+ dashboard: {
+ label: 'Dashboard',
+ renderIcon: ({ className }) => ,
+ },
file: {
label: 'File',
renderIcon: ({ context, className }) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts
new file mode 100644
index 00000000000..08bf945a4bc
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts
@@ -0,0 +1,14 @@
+import { collectRetrievalCitationEvidence } from '@/lib/mothership/chat/citation-evidence'
+import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url'
+import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
+
+/** Only searches with safe sources have displayable details. */
+export function getSearchActivitySources(tool: ToolCallData): SourceTagData[] | undefined {
+ if (tool.toolName !== 'search_workspace') return undefined
+ const evidence = collectRetrievalCitationEvidence([
+ { toolCall: { name: tool.toolName, status: tool.status, result: tool.result } },
+ ])
+ const sources = [...indexSourcesByUrl(evidence.values()).values()]
+ return sources.length > 0 ? sources : undefined
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.tsx
deleted file mode 100644
index d36f0297c89..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- collectRetrievalCitationEvidence,
- parseCitationRecord,
-} from '@/lib/mothership/chat/citation-evidence'
-import { SearchActivityResults } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results'
-import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
-import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url'
-import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
-
-/** Safe sources, an explicit empty result, or no displayable search details. */
-export function getSearchActivitySources(tool: ToolCallData): SourceTagData[] | undefined {
- if (tool.toolName !== 'search_workspace') return undefined
- const evidence = collectRetrievalCitationEvidence([
- { toolCall: { name: tool.toolName, status: tool.status, result: tool.result } },
- ])
- const sources = [...indexSourcesByUrl(evidence.values()).values()]
- const output = parseCitationRecord(tool.result?.output)
- const data = parseCitationRecord(output?.data) ?? output
- const noResults = Boolean(
- tool.status === ToolCallStatus.success &&
- tool.result?.success &&
- output?.success !== false &&
- parseCitationRecord(data?.retrieval)?.status !== 'partial' &&
- Array.isArray(data?.results) &&
- data.results.length === 0
- )
-
- return sources.length > 0 || noResults ? sources : undefined
-}
-
-interface SearchActivityDetailsProps {
- sources: SourceTagData[]
- label: string
-}
-
-/** Per-call evidence stays in the shared activity history, never in the live header. */
-export function SearchActivityDetails({ sources, label }: SearchActivityDetailsProps) {
- return sources.length > 0 ? (
-
- ) : (
- No results
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx
index 80fb8472a62..a56985eba48 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx
@@ -3,7 +3,7 @@
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ActivityStatus } from '@/components/ui/activity-status'
import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
@@ -46,6 +46,8 @@ describe('ToolActivityGroup search disclosure', () => {
let root: Root
beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.useFakeTimers()
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
@@ -54,12 +56,13 @@ describe('ToolActivityGroup search disclosure', () => {
afterEach(() => {
act(() => root.unmount())
container.remove()
+ vi.useRealTimers()
})
- function render(tool: ToolCallData, isLive: boolean) {
+ function render(tools: ToolCallData[], isLive: boolean) {
act(() =>
root.render(
-
+
)
)
}
@@ -71,26 +74,55 @@ describe('ToolActivityGroup search disclosure', () => {
}
it('opens when live results arrive, closes for the answer, and respects manual choices', () => {
- render(executingSearch, true)
+ render([executingSearch], true)
expect(container.querySelector('[role="button"][aria-expanded]')).toBeNull()
- render(completedSearch, true)
+ render([completedSearch], true)
expect(disclosure().getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('a[href="https://example.test/guide"]')).not.toBeNull()
- render(completedSearch, false)
+ render([completedSearch], false)
expect(disclosure().getAttribute('aria-expanded')).toBe('false')
act(() => disclosure().click())
expect(disclosure().getAttribute('aria-expanded')).toBe('true')
- render(completedSearch, false)
+ render([completedSearch], false)
expect(disclosure().getAttribute('aria-expanded')).toBe('true')
- render(completedSearch, true)
+ render([completedSearch], true)
expect(disclosure().getAttribute('aria-expanded')).toBe('true')
act(() => disclosure().click())
- render(completedSearch, false)
- render(completedSearch, true)
+ render([completedSearch], false)
+ render([completedSearch], true)
expect(disclosure().getAttribute('aria-expanded')).toBe('false')
})
+
+ it('keeps empty searches out of the disclosure while subsequent tools run', () => {
+ const emptySearch: ToolCallData = {
+ ...completedSearch,
+ id: 'empty-search',
+ result: { success: true, output: { success: true, data: { results: [] } } },
+ }
+ const runningRead: ToolCallData = {
+ id: 'read-1',
+ toolName: 'read_document',
+ displayTitle: 'Reading document',
+ status: 'executing',
+ }
+
+ render([emptySearch], true)
+ expect.soft(container.querySelector('[role="button"][aria-expanded]')).toBeNull()
+
+ render([emptySearch, runningRead], true)
+ expect.soft(disclosure().getAttribute('aria-expanded')).toBe('false')
+
+ if (disclosure().getAttribute('aria-expanded') === 'false') {
+ act(() => disclosure().click())
+ }
+ expect.soft(container.textContent).not.toContain('No results')
+
+ render([emptySearch, completedSearch], true)
+ expect(container.querySelector('a[href="https://example.test/guide"]')).not.toBeNull()
+ expect(container.textContent).not.toContain('No results')
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
index 6e5b6880956..d3ff86a946a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
@@ -16,10 +16,8 @@ import {
import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display'
import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
import { getNewestRunningTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
-import {
- getSearchActivitySources,
- SearchActivityDetails,
-} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details'
+import { getSearchActivitySources } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details'
+import { SearchActivityResults } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import {
getActivityAttentionKey,
@@ -276,7 +274,7 @@ export function ToolActivityGroup({
)}
{sources && (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
index 5e8d4273732..00894654cba 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
@@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
*/
vi.mock('@/lib/auth/auth-client', () => authClientMock)
+import { formatUsageUpgradeTag } from '@/lib/billing/usage-upgrade'
import type {
ContentSegment,
CredentialItemData,
@@ -875,3 +876,21 @@ describe('source tag', () => {
}
})
})
+
+describe('usage card written to a worker log', () => {
+ it('renders the card Sim hands the worker when the text is replayed after a reload', () => {
+ const usageUpgrade = {
+ reason: 'usage_limit',
+ action: 'increase_limit',
+ message: "You've reached your usage limit for this billing period.",
+ } as const
+ const replayed = `Finished the first report.${formatUsageUpgradeTag(usageUpgrade)}`
+
+ const { segments } = parseSpecialTags(replayed, false)
+
+ expect(segments).toEqual([
+ { type: 'text', content: 'Finished the first report.' },
+ { type: 'usage_upgrade', data: usageUpgrade },
+ ])
+ })
+})
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 e6ee4eabe78..3665d705d12 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
@@ -356,7 +356,7 @@ export interface QuestionItem {
/** Normalized `` payload: single-object bodies become a one-element array. */
export type QuestionTagData = QuestionItem[]
-export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const
+export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'dashboard', 'file'] as const
export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number]
@@ -1930,6 +1930,8 @@ function fallbackWorkspaceResourceTitle(type: WorkspaceResourceTagType): string
return 'Workflow'
case 'table':
return 'Table'
+ case 'dashboard':
+ return 'Dashboard'
case 'file':
return 'File'
}
@@ -1945,6 +1947,8 @@ function toChatMessageContext(data: WorkspaceResourceTagData, label: string): Ch
return { kind: 'workflow', label, workflowId: data.id ?? '' }
case 'table':
return { kind: 'table', label, tableId: data.id ?? '' }
+ case 'dashboard':
+ return { kind: 'dashboard', label, dashboardId: data.id ?? '' }
case 'file':
return { kind: 'file', label, fileId: data.id ?? data.path ?? '' }
}
@@ -2000,13 +2004,15 @@ function WorkspaceResourceDisplayContent({
: data.type === 'table'
? (tables.find((table) => table.id === data.id)?.name ??
fallbackWorkspaceResourceTitle(data.type))
- : data.type === 'file'
- ? (files.find((file) => file.id === data.id)?.name ??
- fileFromPath?.name ??
- data.title ??
- fallbackWorkspaceResourceTitle(data.type))
- : (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ??
- fallbackWorkspaceResourceTitle(data.type))
+ : data.type === 'dashboard'
+ ? (data.title ?? fallbackWorkspaceResourceTitle(data.type))
+ : data.type === 'file'
+ ? (files.find((file) => file.id === data.id)?.name ??
+ fileFromPath?.name ??
+ data.title ??
+ fallbackWorkspaceResourceTitle(data.type))
+ : (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ??
+ fallbackWorkspaceResourceTitle(data.type))
const id = data.id ?? fileFromPath?.id
return {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts
index eb728248daf..70bf34e89cc 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts
@@ -9,7 +9,9 @@ import {
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
+import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { listIntegrationsByPopularity } from '@/blocks/integration-matcher'
+import { useWorkspaceDashboard } from '@/hooks/queries/dashboards'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
@@ -94,6 +96,7 @@ export function useAvailableResources(
workspaceId: string,
options?: UseAvailableResourcesOptions
): AvailableResources {
+ const dashboardsEnabled = useFeatureFlag('dashboards')
const enabled = options?.enabled ?? true
const excludeTypes = options?.excludeTypes
const browserAvailable = useSyncExternalStore(
@@ -115,6 +118,9 @@ export function useAvailableResources(
const { data: tables, isPending: tablesPending } = useTablesList(workspaceId, 'active', {
enabled: enabled && Boolean(workspaceId),
})
+ const { data: dashboardData, isPending: dashboardsPending } = useWorkspaceDashboard(workspaceId, {
+ enabled: enabled && dashboardsEnabled && !excludeTypes?.includes('dashboard'),
+ })
const { data: files, isPending: filesPending } = useWorkspaceFiles(workspaceId, 'active', {
enabled: enabled && Boolean(workspaceId),
})
@@ -165,6 +171,7 @@ export function useAvailableResources(
(workflowsPending ||
tablesPending ||
filesPending ||
+ (dashboardsEnabled && !excludeTypes?.includes('dashboard') && dashboardsPending) ||
knowledgeBasesPending ||
foldersPending ||
(options?.includeFolderMentions &&
@@ -177,6 +184,7 @@ export function useAvailableResources(
const groups = useMemo(() => {
if (!enabled) return NO_RESOURCE_GROUPS
const excluded = new Set(excludeTypes ?? [])
+ if (!dashboardsEnabled) excluded.add('dashboard')
const groups: AvailableItemsByType[] = [
{
type: 'workflow' as const,
@@ -204,6 +212,12 @@ export function useAvailableResources(
folderId: t.folderId ?? null,
})),
},
+ {
+ type: 'dashboard' as const,
+ items: dashboardData?.dashboard
+ ? [{ id: dashboardData.dashboard.id, name: dashboardData.dashboard.name, folderId: null }]
+ : [],
+ },
{
type: 'file' as const,
items: (files ?? []).map((f) => ({ id: f.id, name: f.name, folderId: f.folderId ?? null })),
@@ -296,10 +310,12 @@ export function useAvailableResources(
fileFolders,
tables,
files,
+ dashboardData,
knowledgeBases,
tasks,
logs,
excludeTypes,
+ dashboardsEnabled,
])
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 8f5d9879a79..0685009d447 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -23,6 +23,7 @@ import {
import { createLogger } from '@sim/logger'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
+import { DashboardResource } from '@/components/dashboards/dashboard-resource'
import { isApiClientError } from '@/lib/api/client/errors'
import type { MothershipTableViewContext } from '@/lib/api/contracts/mothership-resources'
import { useSession } from '@/lib/auth/auth-client'
@@ -50,6 +51,7 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import { LogDetailsContent } from '@/app/workspace/[workspaceId]/logs/components'
+import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import {
useUserPermissionsContext,
@@ -297,6 +299,8 @@ export const ResourceContent = memo(function ResourceContent({
/>
)
+ case 'dashboard':
+ return
case 'file':
return (
)
+ case 'dashboard':
+ return
case 'table':
return
case 'log':
@@ -518,6 +524,32 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
)
}
+interface EmbeddedDashboardActionsProps {
+ workspaceId: string
+}
+
+function EmbeddedDashboardActions({ workspaceId }: EmbeddedDashboardActionsProps) {
+ const router = useRouter()
+ const dashboardsEnabled = useFeatureFlag('dashboards')
+ if (!dashboardsEnabled) return null
+ return (
+
+
+ router.push(`/workspace/${workspaceId}/dashboards`)}
+ aria-label='Open dashboard'
+ >
+
+
+
+
+ Open dashboard
+
+
+ )
+}
+
interface EmbeddedKnowledgeBaseActionsProps {
workspaceId: string
knowledgeBaseId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts
index 8879ff646bf..6962cdab5f8 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts
@@ -1,5 +1,6 @@
import type { QueryClient, QueryKey } from '@tanstack/react-query'
import type { MothershipResourceType } from '@/lib/mothership/resources/types'
+import { dashboardKeys } from '@/hooks/queries/dashboards'
import { deploymentKeys, invalidateDeploymentQueries } from '@/hooks/queries/deployments'
import { logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
@@ -26,6 +27,7 @@ const RESOURCE_INVALIDATORS: Record<
invalidate(qc, id ? tableKeys.detail(id) : tableKeys.details())
invalidate(qc, id ? tableKeys.views(id) : tableKeys.viewsRoot())
},
+ dashboard: (qc, wId) => invalidate(qc, dashboardKeys.workspace(wId)),
file: (qc, wId, id) => {
invalidate(qc, workspaceFilesKeys.lists())
invalidate(qc, id ? workspaceFilesKeys.record(wId, id) : workspaceFilesKeys.records())
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
index 5e99545a497..1a9de189f0d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
@@ -4,6 +4,7 @@ import type { ElementType, ReactNode } from 'react'
import { cn, OverflowText } from '@sim/emcn'
import {
Connections,
+ Dashboard,
Database,
File as FileIcon,
Folder as FolderIcon,
@@ -183,6 +184,15 @@ export const RESOURCE_REGISTRY: Record ,
},
+ dashboard: {
+ type: 'dashboard',
+ label: 'Dashboards',
+ icon: Dashboard,
+ renderTabIcon: (_resource, className) => (
+
+ ),
+ renderDropdownItem: (props) => ,
+ },
file: {
type: 'file',
label: 'Files',
@@ -279,16 +289,16 @@ export const RESOURCE_REGISTRY: Record ({ kind: 'dashboard', dashboardId: r.id, label: r.title }),
file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }),
folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }),
filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }),
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options.ts
index 6f5bf920ad2..1789904fb99 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options.ts
@@ -1,16 +1,20 @@
import { BUILTIN_SKILLS, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
import type { SkillDefinition } from '@/hooks/queries/skills'
-/** Built-ins are global templates; only user-defined skills carry a workspace. */
+/**
+ * Built-ins are global templates; only user-defined skills carry a workspace. Rollout-gated
+ * built-ins (the dashboard skill) are excluded the same way the server skill lists exclude them.
+ */
export function organizationSkillOptions(
workspaces: ReadonlyArray<{
id: string
name: string
skills: readonly SkillDefinition[]
- }>
+ }>,
+ excludedBuiltinIds: readonly string[] = []
): (SkillDefinition & { workspaceName?: string })[] {
return [
- ...BUILTIN_SKILLS.map((skill) => ({
+ ...BUILTIN_SKILLS.filter((skill) => !excludedBuiltinIds.includes(skill.id)).map((skill) => ({
...skill,
workspaceId: null,
userId: null,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index 0b99341558f..b65a031654f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -259,6 +259,8 @@ const UserInputImpl = forwardRef(function UserI
return `knowledge:${ctx.knowledgeId ?? ''}`
case 'table':
return `table:${ctx.tableId}`
+ case 'dashboard':
+ return `dashboard:${ctx.dashboardId}`
case 'file':
return `file:${ctx.fileId}`
case 'folder':
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index 3b676da406f..a41c43e3d8c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -314,6 +314,8 @@ function HomeContent({ chatId, userName, userId }: HomeProps) {
return context.fileId ? { type: 'file', id: context.fileId } : null
case 'file_selection':
return context.fileId ? { type: 'file', id: context.fileId } : null
+ case 'dashboard':
+ return { type: 'dashboard', id: context.dashboardId }
default:
return null
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx
index 95fe0c04e6c..acd0efa6d15 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx
@@ -73,6 +73,7 @@ import {
seedDeploymentShape,
} from '@/lib/core/config/deployment-shape'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
+import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants'
import type { MothershipStreamV1EventEnvelope } from '@/lib/mothership/generated/mothership-stream-v1'
import { getChatResourceSelectionId } from '@/lib/mothership/resources/types'
import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
@@ -1089,6 +1090,145 @@ describe('useChat remount send recovery', () => {
}
})
+ it('keeps re-attaching a long turn whose tails deliver events between separate network failures', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ let tails = 0
+ const history: MothershipChatHistory = {
+ id: 'chat-long-turn',
+ mode: 'agent',
+ title: 'Long turn',
+ messages: [],
+ activeStreamId: null,
+ resources: [],
+ }
+ mockRequestJson.mockImplementation(() =>
+ Promise.resolve({
+ chat: { ...history, activeStreamId: state.postBodies[0]?.userMessageId ?? null },
+ })
+ )
+ state.postBehavior = 'accept'
+ vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input)
+ if (!url.includes('/api/mothership/chat/stream')) return fetchStub(input, init)
+ if (url.includes('batch=true')) {
+ return Response.json({ success: true, events: [], status: 'streaming' })
+ }
+ tails++
+ const streamId = state.postBodies[0]?.userMessageId ?? ''
+ const event: MothershipStreamV1EventEnvelope = {
+ v: 1,
+ seq: tails,
+ ts: new Date().toISOString(),
+ type: 'text',
+ stream: { streamId, cursor: String(tails) },
+ payload: { channel: 'assistant', text: `part ${tails} ` },
+ }
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
+ },
+ pull(controller) {
+ controller.error(new TypeError('network error'))
+ },
+ }),
+ { headers: { 'Content-Type': 'text/event-stream' } }
+ )
+ })
+ const { getResult } = renderUseChatInChat(history.id, history)
+ await act(async () => {
+ void getResult().sendMessage('Keep going for hours')
+ })
+ const errors = new Set()
+ let seconds = 0
+ for (; seconds < 600 && tails < 15; seconds++) {
+ await act(async () => vi.advanceTimersByTimeAsync(1_000))
+ const error = getResult().error
+ if (error) errors.add(error)
+ }
+
+ expect(tails).toBeGreaterThanOrEqual(15)
+ /* Each failure after a tail that delivered events retries at the base delay. */
+ expect(seconds).toBeLessThan(60)
+ expect([...errors]).toEqual([])
+ expect(getResult().isSending).toBe(true)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('rebuilds the turn from an empty response when a reconnect is re-synced from the log, and stays on the log', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ let tails = 0
+ const streamUrls: string[] = []
+ const history: MothershipChatHistory = {
+ id: 'chat-log-resync',
+ mode: 'agent',
+ title: 'Log re-sync',
+ messages: [],
+ activeStreamId: null,
+ resources: [],
+ }
+ mockRequestJson.mockImplementation(() =>
+ Promise.resolve({
+ chat: { ...history, activeStreamId: state.postBodies[0]?.userMessageId ?? null },
+ })
+ )
+ state.postBehavior = 'accept'
+ const frame = (streamId: string, seq: number, text: string) =>
+ `data: ${JSON.stringify({
+ v: 1,
+ seq,
+ ts: new Date().toISOString(),
+ type: 'text',
+ stream: { streamId, cursor: String(seq) },
+ payload: { channel: 'assistant', text },
+ } satisfies MothershipStreamV1EventEnvelope)}\n\n`
+ vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input)
+ if (!url.includes('/api/mothership/chat/stream')) return fetchStub(input, init)
+ streamUrls.push(url)
+ if (url.includes('batch=true')) {
+ return Response.json({ success: true, events: [], status: 'streaming' })
+ }
+ tails++
+ const streamId = state.postBodies[0]?.userMessageId ?? ''
+ if (tails === 1) {
+ return new Response([1, 2, 3].map((seq) => frame(streamId, seq, 'stale ')).join(''), {
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ }
+ return new Response(frame(streamId, 1, 'Full response.'), {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log',
+ },
+ })
+ })
+ const { getResult } = renderUseChatInChat(history.id, history)
+ await act(async () => {
+ void getResult().sendMessage('Pick up where it left off')
+ })
+ for (let second = 0; second < 10 && tails < 3; second++) {
+ await act(async () => vi.advanceTimersByTimeAsync(1_000))
+ }
+
+ const answer = getResult().messages.find((message) => message.role === 'assistant')
+ expect(tails).toBeGreaterThanOrEqual(3)
+ expect(answer?.content).toBe('Full response.')
+ const logResyncTail = streamUrls.findIndex(
+ (url) => url.includes('after=3') && !url.includes('batch=true')
+ )
+ const afterLogResync = streamUrls.slice(logResyncTail + 1)
+ expect(afterLogResync.length).toBeGreaterThan(0)
+ expect(afterLogResync.every((url) => url.includes('source=log'))).toBe(true)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('sends a queued correction after stopping with more than 10 MiB of tool input', async () => {
state.postBehavior = 'tool'
state.toolInputPadding = 'x'.repeat(11 * 1024 * 1024)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 92da00740fe..805b8fb6604 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -54,7 +54,11 @@ import {
type RevealedSimKeysByMessage,
restoreRevealedSimKeysForMessage,
} from '@/lib/mothership/chat/sim-key-redaction'
-import { MOTHERSHIP_CHAT_API_PATH, MOTHERSHIP_CHAT_ID_HEADER } from '@/lib/mothership/constants'
+import {
+ MOTHERSHIP_CHAT_API_PATH,
+ MOTHERSHIP_CHAT_ID_HEADER,
+ MOTHERSHIP_STREAM_REPLAY_HEADER,
+} from '@/lib/mothership/constants'
import { sendMothershipMessage } from '@/lib/mothership/events'
import type { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
import { resolveMothershipModelSettings } from '@/lib/mothership/model-options'
@@ -302,6 +306,15 @@ const EMPTY_MESSAGE_QUEUE: QueuedMothershipMessage[] = []
const logger = createLogger('useChat')
+/**
+ * The reconnect query for a stream. Once a stream was re-synced from the worker's log,
+ * its cursors are log positions, so every later read names the log as its source and
+ * is never served from the replay ring, even one that restarted and grew past them.
+ */
+function streamReconnectQuery(streamId: string, afterCursor: string, fromLog: boolean): string {
+ return `streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(afterCursor)}${fromLog ? '&source=log' : ''}`
+}
+
/**
* Fire-and-forget desktop-surface handoff between chat scopes: drops an
* abandoned pending scope (never a durable one) before activating the next.
@@ -952,6 +965,7 @@ export function useChat(
const streamRequestIdRef = useRef(undefined)
const locallyTerminalStreamIdRef = useRef(undefined)
const lastCursorRef = useRef('0')
+ const logResyncedStreamIdRef = useRef(null)
const activeStreamReturnRecoveryRef = useRef(null)
const sendingRef = useRef(false)
const streamGenRef = useRef(0)
@@ -2367,7 +2381,7 @@ export function useChat(
)
// boundary-raw-fetch: stream-resume batch endpoint requires dynamic per-request traceparent header propagation that the contract layer does not model, and the response is consumed alongside live SSE tail fetches
const response = await fetch(
- `/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(afterCursor)}&batch=true`,
+ `/api/mothership/chat/stream?${streamReconnectQuery(streamId, afterCursor, logResyncedStreamIdRef.current === streamId)}&batch=true`,
{
signal: fetchSignal,
...(streamTraceparentRef.current
@@ -2559,7 +2573,7 @@ export function useChat(
// boundary-raw-fetch: live SSE tail endpoint streams events consumed via response.body.getReader() and processSSEStream
const sseRes = await fetch(
- `/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(latestCursor)}`,
+ `/api/mothership/chat/stream?${streamReconnectQuery(streamId, latestCursor, logResyncedStreamIdRef.current === streamId)}`,
{
signal: activeAbort.signal,
...(streamTraceparentRef.current
@@ -2578,6 +2592,14 @@ export function useChat(
return { error: false, aborted: true }
}
+ // Re-sent from the worker's log with cursors restarting at 1: rebuild from empty.
+ if (sseRes.headers.get(MOTHERSHIP_STREAM_REPLAY_HEADER) === 'log') {
+ logResyncedStreamIdRef.current = streamId
+ const reset = applyReconnectReplaySelection(streamId, '0')
+ latestCursor = reset.afterCursor
+ preserveNextReplayState = reset.preserveExistingState
+ }
+
setTransportStreaming()
const liveResult = await processSSEStreamRef.current(
@@ -2765,8 +2787,16 @@ export function useChat(
abortControllerRef.current?.signal.aborted === true ||
shouldContinue?.() === false
- for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
+ /**
+ * An attempt whose tail delivered new events re-attached successfully, so
+ * the failure after it starts a fresh budget at the base delay. Only
+ * failures without progress count toward exhaustion, which keeps separate
+ * network drops hours apart in a long turn from adding up.
+ */
+ let attempt = 0
+ while (attempt <= MAX_RECONNECT_ATTEMPTS) {
if (isStaleReconnect()) return true
+ const cursorBeforeAttempt = lastCursorRef.current
if (attempt > 0) {
const delayMs = Math.min(
@@ -2868,6 +2898,7 @@ export function useChat(
error: toError(err).message,
})
}
+ attempt = lastCursorRef.current !== cursorBeforeAttempt ? 1 : attempt + 1
}
logger.error('All reconnect attempts exhausted', {
@@ -3392,6 +3423,7 @@ export function useChat(
? { viewId: (c.currentView ? c.currentView.viewId : c.viewId) ?? undefined }
: {}),
...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}),
+ ...('dashboardId' in c && c.dashboardId ? { dashboardId: c.dashboardId } : {}),
...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}),
...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}),
...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}),
diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts
index 71f7845fac9..9a5613ecf93 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts
@@ -166,6 +166,7 @@ export interface ChatMessageContext {
tableId?: string
viewId?: string
fileId?: string
+ dashboardId?: string
folderId?: string
chatId?: string
blockType?: string
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx
index a461e2ad280..04993a39a24 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx
@@ -1,5 +1,6 @@
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
+import { getManagedMcpConnectorBgColor } from '@/lib/credential-groups/managed-mcp-connectors'
import { BrandTile } from '@/app/workspace/[workspaceId]/components/resource-tile'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
@@ -47,11 +48,11 @@ const SHOWCASE_TILES = [
] as const
/**
- * Resolves the brand background color for a block type from the block registry.
- * Returns `null` when the block is unknown or has no brand color configured.
+ * Resolves the brand background color for workflow blocks and managed MCP connectors.
+ * Returns `null` when neither catalog provides a brand color.
*/
function resolveBrandTileBg(blockType: string): string | null {
- return getBlock(blockType)?.bgColor || null
+ return getBlock(blockType)?.bgColor || getManagedMcpConnectorBgColor(blockType) || null
}
interface IntegrationTileProps {
@@ -61,7 +62,7 @@ interface IntegrationTileProps {
}
/**
- * Brand-colored square tile that renders a block's icon. The unframed variant
+ * Brand-colored square tile that renders an integration's icon. The unframed variant
* is a 36px tile used in list rows and headers; the framed variant adds an
* outer 44px halo used inside the showcase grid.
*/
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index 79019d63b37..d9fcd6f9a9e 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
+import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
@@ -65,6 +66,7 @@ export default async function WorkspaceLayout({
modelSelectorEnabled,
planModeEnabled,
organizationHref,
+ dashboardsEnabled,
] = await Promise.all([
cookies(),
hostContext.hostOrganizationId
@@ -81,6 +83,7 @@ export default async function WorkspaceLayout({
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
resolveOrganizationEntryPath(session),
+ isDashboardsEnabled(hostContext.hostOrganizationId),
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
])
@@ -90,6 +93,7 @@ export default async function WorkspaceLayout({
{
if (allAlreadySelected) return
const filteredTools = selectedTools.filter(
@@ -1350,7 +1354,10 @@ export const ToolInput = memo(function ToolInput({
serverItems.push({
label: `${serverName} (${toolCount} tools)`,
value: `mcp-server-folder-${serverId}`,
- iconElement: createToolIcon('#6366F1', ServerIcon),
+ iconElement: createToolIcon(
+ getManagedMcpConnectorBgColor(server.managedConnectorId) ?? '#6366F1',
+ ServerIcon
+ ),
suffixElement: ,
onSelect: () => {
setMcpServerDrilldown(serverId)
@@ -1572,7 +1579,10 @@ export const ToolInput = memo(function ToolInput({
: advancedMcpServer?.managedConnectorId
? getManagedMcpConnectorIcon(advancedMcpServer.managedConnectorId)
: McpIcon
- const mcpTileColor = mcpTool?.bgColor || 'var(--brand-agent)'
+ const mcpTileColor =
+ mcpTool?.bgColor ||
+ getManagedMcpConnectorBgColor(advancedMcpServer?.managedConnectorId) ||
+ 'var(--brand-agent)'
const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null
// Canonical name wins; stored title only when nothing resolves
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index c111d8ae785..5cbefeed900 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -25,6 +25,7 @@ import {
} from '@sim/emcn'
import {
Building,
+ Dashboard,
Database,
Files,
Integration,
@@ -53,6 +54,7 @@ import { captureEvent } from '@/lib/posthog/client'
import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file'
import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
+import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -705,6 +707,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
[workspaces, workspaceId]
)
+ const dashboardsEnabled = useFeatureFlag('dashboards')
const topNavItems = useMemo(
() =>
[
@@ -721,6 +724,14 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
(chatEnabled && permissionConfig.hideCopilot && !accessRequestsEnabled),
restricted: chatEnabled && permissionConfig.hideCopilot,
},
+ {
+ id: 'dashboards',
+ label: 'Dashboard',
+ icon: Dashboard,
+ href: `/workspace/${workspaceId}/dashboards`,
+ hidden: !dashboardsEnabled || (permissionConfig.hideFilesTab && !accessRequestsEnabled),
+ restricted: permissionConfig.hideFilesTab,
+ },
{
id: 'integrations',
label: 'Integrations',
@@ -738,8 +749,10 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
permissionsLoading,
permissionConfig.hideIntegrationsTab,
permissionConfig.hideCopilot,
+ permissionConfig.hideFilesTab,
accessRequestsEnabled,
chatEnabled,
+ dashboardsEnabled,
]
)
diff --git a/apps/sim/components/charts/echarts-view.test.tsx b/apps/sim/components/charts/echarts-view.test.tsx
new file mode 100644
index 00000000000..f7bca6f47f9
--- /dev/null
+++ b/apps/sim/components/charts/echarts-view.test.tsx
@@ -0,0 +1,76 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { EChartsView } from '@/components/charts/echarts-view'
+
+const mocks = vi.hoisted(() => ({
+ init: vi.fn(),
+ setOption: vi.fn(),
+ dispose: vi.fn(),
+ fontLoad: vi.fn(),
+ theme: 'light',
+}))
+vi.mock('echarts', () => ({ init: mocks.init, use: vi.fn() }))
+vi.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: mocks.theme }) }))
+vi.mock('@/lib/charts/theme', () => ({
+ readEmcnChartTheme: () => ({}),
+ applyChartTooltipDefaults: (option: unknown) => option,
+}))
+
+describe('EChartsView updates', () => {
+ let root: Root
+ let container: HTMLDivElement
+
+ async function render(value: number) {
+ await act(async () => {
+ root.render( )
+ })
+ }
+
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ disconnect() {}
+ }
+ )
+ Object.defineProperty(document, 'fonts', {
+ configurable: true,
+ value: { load: mocks.fontLoad },
+ })
+ mocks.fontLoad.mockResolvedValue([])
+ mocks.theme = 'light'
+ mocks.init.mockReturnValue({
+ setOption: mocks.setOption,
+ dispose: mocks.dispose,
+ resize: vi.fn(),
+ })
+ container = document.createElement('div')
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ })
+
+ it('updates data without replacing the chart with a loading screen', async () => {
+ await render(38)
+ await vi.waitFor(() => expect(container.querySelector('[role="status"]')).toBeNull())
+ await render(12)
+ expect(container.querySelector('[role="status"]')).toBeNull()
+ })
+
+ it('reports update failures and recovers on a later valid option', async () => {
+ await render(38)
+ mocks.setOption.mockImplementationOnce(() => {
+ throw new Error('Invalid chart option')
+ })
+ await render(12)
+ expect(container.querySelector('[role="alert"]')).not.toBeNull()
+ await render(6)
+ expect(container.querySelector('[role="alert"]')).toBeNull()
+ })
+})
diff --git a/apps/sim/components/charts/echarts-view.tsx b/apps/sim/components/charts/echarts-view.tsx
new file mode 100644
index 00000000000..041ef6c5945
--- /dev/null
+++ b/apps/sim/components/charts/echarts-view.tsx
@@ -0,0 +1,141 @@
+'use client'
+
+import { useEffect, useEffectEvent, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { EChartsType } from 'echarts'
+import { useTheme } from 'next-themes'
+import { installBarRowHighlight } from '@/lib/charts/bar-row-highlight'
+import { chartSummaryExtension } from '@/lib/charts/summary'
+import { applyChartTooltipDefaults, readEmcnChartTheme } from '@/lib/charts/theme'
+
+interface EChartsViewProps {
+ option: Record
+ label: string
+ className?: string
+ createController?: (chart: EChartsType) => EChartsController
+ revision?: string
+}
+
+export interface EChartsController {
+ prepareOption: (option: Record) => Record
+ afterUpdate: () => void
+ dispose: () => void
+}
+
+/**
+ * Both chart documents and dashboards use this canvas lifecycle and EMCN theme.
+ * Canvas tracking must match ECharts' detached measuring canvas, which has no CSS letter spacing.
+ */
+export function EChartsView({
+ option,
+ label,
+ className,
+ createController,
+ revision,
+}: EChartsViewProps) {
+ const containerRef = useRef(null)
+ const chartRef = useRef(null)
+ const controllerRef = useRef(null)
+ const rowHighlightRef = useRef<(() => void) | null>(null)
+ const { resolvedTheme } = useTheme()
+ const [status, setStatus] = useState<{ theme: string | undefined; error?: string } | null>(null)
+ const optionKey = JSON.stringify(option)
+ const applyOption = useEffectEvent((chart: EChartsType, nextOption: string) => {
+ try {
+ controllerRef.current?.dispose()
+ controllerRef.current = null
+ const controller = createController?.(chart)
+ controllerRef.current = controller ?? null
+ const parsed = applyChartTooltipDefaults(JSON.parse(nextOption))
+ chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true })
+ controller?.afterUpdate()
+ rowHighlightRef.current?.()
+ rowHighlightRef.current = installBarRowHighlight(chart, parsed)
+ setStatus({ theme: resolvedTheme })
+ } catch (error) {
+ controllerRef.current?.dispose()
+ controllerRef.current = null
+ setStatus({ theme: resolvedTheme, error: getErrorMessage(error, 'Unable to render chart') })
+ }
+ })
+ const onChartReady = useEffectEvent((chart: EChartsType) => applyOption(chart, optionKey))
+
+ useEffect(() => {
+ let active = true
+ let dispose: (() => void) | undefined
+ const element = containerRef.current
+ if (!element) return
+ const font = getComputedStyle(element)
+ Promise.all([
+ import('echarts'),
+ document.fonts.load(`${font.fontWeight} ${font.fontSize} ${font.fontFamily}`),
+ ])
+ .then(([echarts]) => {
+ if (!active) return
+ echarts.use(chartSummaryExtension)
+ const chart = echarts.init(element, readEmcnChartTheme(element), { renderer: 'canvas' })
+ dispose = () => chart.dispose()
+ chartRef.current = chart
+ const observer = new ResizeObserver(() => chart.resize())
+ observer.observe(element)
+ dispose = () => {
+ observer.disconnect()
+ chart.dispose()
+ }
+ onChartReady(chart)
+ })
+ .catch((error) => {
+ if (active) {
+ dispose?.()
+ dispose = undefined
+ chartRef.current = null
+ setStatus({
+ theme: resolvedTheme,
+ error: getErrorMessage(error, 'Unable to render chart'),
+ })
+ }
+ })
+ return () => {
+ active = false
+ rowHighlightRef.current?.()
+ rowHighlightRef.current = null
+ controllerRef.current?.dispose()
+ controllerRef.current = null
+ chartRef.current = null
+ dispose?.()
+ }
+ }, [resolvedTheme])
+
+ useEffect(() => {
+ if (chartRef.current) applyOption(chartRef.current, optionKey)
+ }, [optionKey, revision])
+
+ const current = status?.theme === resolvedTheme ? status : null
+ return (
+
+
+ {!current && (
+
+ Loading chart…
+
+ )}
+ {current?.error && (
+
+ {current.error}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/components/charts/time-series-chart.tsx b/apps/sim/components/charts/time-series-chart.tsx
new file mode 100644
index 00000000000..8554a8f4df0
--- /dev/null
+++ b/apps/sim/components/charts/time-series-chart.tsx
@@ -0,0 +1,81 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { cn, scrollFadeAttributes, scrollFadeXClass, useScrollEdges } from '@sim/emcn'
+import { EChartsView } from '@/components/charts/echarts-view'
+import {
+ bindTimeSeriesInteractions,
+ type ChartReadout,
+ type TimeSeriesInteractionOptions,
+} from '@/lib/charts/time-series'
+import { dashboardTimeLabel } from '@/lib/dashboards/time'
+
+interface TimeSeriesChartProps extends Omit {
+ label: string
+ option: Record
+}
+
+export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartProps) {
+ const valuesRef = useRef(null)
+ const edges = useScrollEdges(valuesRef, { axis: 'x' })
+ const [readout, setReadout] = useState(null)
+ const hasValues = Boolean(readout?.values.length)
+ return (
+
+
+
+
+ {readout?.values.map((entry, index) => (
+
+
+
+
+ {entry.name}:
+ {entry.value}
+ {entry.summary && {entry.summary} }
+
+ ))}
+
+
+ {readout?.time != null && (
+
+ {dashboardTimeLabel(readout.time, config.timeZone)}
+
+ )}
+
+
+ bindTimeSeriesInteractions(chart, {
+ ...config,
+ onReadout: (next) =>
+ setReadout((previous) =>
+ JSON.stringify(previous) === JSON.stringify(next) ? previous : next
+ ),
+ })
+ }
+ />
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-controls.tsx b/apps/sim/components/dashboards/dashboard-controls.tsx
new file mode 100644
index 00000000000..f60fdf47fb2
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-controls.tsx
@@ -0,0 +1,168 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Calendar,
+ Chip,
+ ChipDropdown,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Tooltip,
+} from '@sim/emcn'
+import { Check, ChevronDown, ChevronLeft, Clock, RefreshCw } from '@sim/emcn/icons'
+import { getBrowserTimezone, zonedWallClock } from '@/lib/core/utils/timezone'
+import type { DashboardRange } from '@/lib/dashboards/spec'
+import { type DashboardTimeRange, dashboardTimeLabel } from '@/lib/dashboards/time'
+
+interface DashboardControlsProps {
+ period: DashboardRange | 'custom'
+ range: DashboardTimeRange
+ timeZone: string
+ zone: 'utc' | 'local'
+ isFetching: boolean
+ rangeError: boolean
+ onPeriodChange: (period: DashboardRange | 'custom') => void
+ onCalendarChange: (from: string, to: string) => boolean
+ onZoneChange: (zone: 'utc' | 'local') => void
+ onRefresh: () => void
+}
+const RANGE_OPTIONS = [
+ { value: '1h', label: 'Last hour' },
+ { value: '24h', label: 'Last 24 hours' },
+ { value: '7d', label: 'Last 7 days' },
+ { value: '30d', label: 'Last 30 days' },
+ { value: '90d', label: 'Last 90 days' },
+] as const
+
+export function DashboardControls({
+ period,
+ range,
+ timeZone,
+ zone,
+ isFetching,
+ rangeError,
+ onPeriodChange,
+ onCalendarChange,
+ onZoneChange,
+ onRefresh,
+}: DashboardControlsProps) {
+ const [open, setOpen] = useState(false)
+ const [custom, setCustom] = useState(false)
+ const localTimeZone = getBrowserTimezone()
+ const zoneLabel =
+ new Intl.DateTimeFormat('en-US', { timeZone: localTimeZone, timeZoneName: 'short' })
+ .formatToParts(new Date(range.to))
+ .find((part) => part.type === 'timeZoneName')?.value ?? localTimeZone
+ const from = new Date(range.from)
+ const to = new Date(Date.parse(range.to) - 1)
+ const fromLocal = zonedWallClock(from, timeZone)
+ const toLocal = zonedWallClock(to, timeZone)
+ const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10)
+ const dates = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ month: 'short',
+ day: 'numeric',
+ year: fromLocal.slice(0, 4) === toLocal.slice(0, 4) ? undefined : 'numeric',
+ hour: sameDay ? '2-digit' : undefined,
+ minute: sameDay ? '2-digit' : undefined,
+ hourCycle: 'h23',
+ })
+ const label =
+ period === 'custom'
+ ? rangeError
+ ? 'Custom: choose range'
+ : `Custom: ${dates.formatRange(from, to)}`
+ : RANGE_OPTIONS.find((option) => option.value === period)!.label
+ return (
+
+
{
+ setOpen(next)
+ if (next) setCustom(period === 'custom')
+ }}
+ >
+
+
+ {label}
+
+
+
+ {custom ? (
+
+ setCustom(false)}>
+ Time ranges
+
+ {
+ if (onCalendarChange(from, to)) setOpen(false)
+ }}
+ onCancel={() => setOpen(false)}
+ />
+
+ ) : (
+
+ {RANGE_OPTIONS.map((option) => (
+ {
+ onPeriodChange(option.value)
+ setOpen(false)
+ }}
+ >
+ {option.label}
+
+ ))}
+ setCustom(true)}>
+ Custom range…
+
+
+ )}
+
+
+
{
+ if (value !== 'utc' && value !== 'local') throw new Error('Invalid dashboard timezone')
+ onZoneChange(value)
+ }}
+ />
+
+
+
+
+ Refresh dashboard
+
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-feature-gate.tsx b/apps/sim/components/dashboards/dashboard-feature-gate.tsx
new file mode 100644
index 00000000000..cc7e6a297c9
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-feature-gate.tsx
@@ -0,0 +1,19 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
+
+interface DashboardFeatureGateProps {
+ children: ReactNode
+}
+
+export function DashboardFeatureGate({ children }: DashboardFeatureGateProps) {
+ const enabled = useFeatureFlag('dashboards')
+ return enabled ? (
+ children
+ ) : (
+
+ Dashboards are not enabled
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-interactions.tsx b/apps/sim/components/dashboards/dashboard-interactions.tsx
new file mode 100644
index 00000000000..8dacd53a7b4
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-interactions.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import { createContext, useContext } from 'react'
+import type { DashboardTimeRange } from '@/lib/dashboards/time'
+import type { DashboardCursorStore } from '@/stores/dashboards/cursor'
+
+interface DashboardInteractions {
+ cursorStore: DashboardCursorStore
+ timeZone: string
+ onZoom: (range: DashboardTimeRange) => void
+}
+export const DashboardInteractionContext = createContext(null)
+
+export function useDashboardInteractions() {
+ const context = useContext(DashboardInteractionContext)
+ if (!context) throw new Error('Dashboard interactions require a dashboard provider')
+ return context
+}
diff --git a/apps/sim/components/dashboards/dashboard-layout.tsx b/apps/sim/components/dashboards/dashboard-layout.tsx
new file mode 100644
index 00000000000..f3c876c97bb
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-layout.tsx
@@ -0,0 +1,110 @@
+'use client'
+
+import { cn, TabStrip } from '@sim/emcn'
+import { useQueryState } from 'nuqs'
+import { DashboardPanel } from '@/components/dashboards/dashboard-panel'
+import { dashboardTabParser, dashboardUrlOptions } from '@/components/dashboards/search-params'
+import type { DashboardBlock, DashboardSource, DashboardTabs } from '@/lib/dashboards/spec'
+import type { DashboardTimeRange } from '@/lib/dashboards/time'
+
+interface DashboardLayoutProps {
+ blocks: DashboardBlock[]
+ defaults?: DashboardSource
+ workspaceId: string
+ dashboardId: string
+ range: DashboardTimeRange
+ now: number
+ path?: string
+ startIndex?: number
+}
+interface DashboardTabsProps extends Omit {
+ block: DashboardTabs
+ path: string
+}
+
+const ROW_GROW: Record = {
+ 1: 'grow',
+ 2: 'grow-2',
+ 3: 'grow-3',
+ 4: 'grow-4',
+ 5: 'grow-5',
+ 6: 'grow-6',
+ 7: 'grow-7',
+ 8: 'grow-8',
+ 9: 'grow-9',
+ 10: 'grow-10',
+ 11: 'grow-11',
+ 12: 'grow-12',
+}
+
+function DashboardTabGroup({ block, path, ...props }: DashboardTabsProps) {
+ const [selected, setSelected] = useQueryState(
+ `dash-${props.dashboardId}-tab-${path}`,
+ dashboardTabParser.withOptions(dashboardUrlOptions)
+ )
+ const names = Object.keys(block.tabs)
+ const active = selected !== null && names.includes(selected) ? selected : names[0]
+ return (
+
+ ({ id: name, title: name, active: name === active }))}
+ onSelect={(name) => void setSelected(name)}
+ className='mb-8 [--tab-strip-inline-start:0px]'
+ />
+
+
+ )
+}
+
+export function DashboardLayout({
+ blocks,
+ path = 'root',
+ startIndex = 0,
+ ...props
+}: DashboardLayoutProps) {
+ return (
+
+ {blocks.map((block, index) => {
+ const key = `${path}.${index + startIndex}`
+ if ('text' in block)
+ return (
+
+ {block.text}
+
+ )
+ if ('tabs' in block)
+ return
+ if ('row' in block) {
+ const metrics = block.row.every((child) => 'stat' in child)
+ return (
+
+ {block.row.map((child, childIndex) => (
+
+
+
+ ))}
+
+ )
+ }
+ return
+ })}
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-loading.tsx b/apps/sim/components/dashboards/dashboard-loading.tsx
new file mode 100644
index 00000000000..b18a882078c
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-loading.tsx
@@ -0,0 +1,11 @@
+import { Loader } from '@sim/emcn/icons'
+
+/** Kept apart from the dashboard renderer so the route's loading fallback stays light. */
+export function DashboardLoading() {
+ return (
+
+
+ Loading dashboard
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-panel.test.tsx b/apps/sim/components/dashboards/dashboard-panel.test.tsx
new file mode 100644
index 00000000000..07cfdf3a2e1
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-panel.test.tsx
@@ -0,0 +1,72 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DashboardInteractionContext } from '@/components/dashboards/dashboard-interactions'
+import { DashboardPanel } from '@/components/dashboards/dashboard-panel'
+import type { QueryTableAnalyticsResponse } from '@/lib/api/contracts/table-analytics'
+import { createDashboardCursorStore } from '@/stores/dashboards/cursor'
+
+const mocks = vi.hoisted(() => ({ query: vi.fn() }))
+vi.mock('@/hooks/queries/table-analytics', () => ({ useTableAnalytics: mocks.query }))
+vi.mock('@/components/charts/echarts-view', () => ({
+ EChartsView: ({ label }: { label: string }) =>
,
+}))
+
+describe('dashboard empty-range transitions', () => {
+ let root: Root
+ let container: HTMLDivElement
+ const interactions = {
+ cursorStore: createDashboardCursorStore(),
+ timeZone: 'UTC',
+ onZoom: vi.fn(),
+ }
+ async function render(rows: QueryTableAnalyticsResponse['rows']) {
+ mocks.query.mockReturnValue({
+ data: {
+ rows,
+ columns: ['total'],
+ columnLabels: { total: 'total' },
+ bucket: null,
+ truncated: false,
+ },
+ isPending: false,
+ isError: false,
+ isFetching: false,
+ })
+ await act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ }
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ container = document.createElement('div')
+ root = createRoot(container)
+ })
+ afterEach(() => {
+ act(() => root.unmount())
+ })
+
+ it('keeps the chart mounted through populated, empty, and populated ranges', async () => {
+ await render([{ total: 38 }])
+ const chart = container.querySelector('[role="img"]')
+ expect(chart).not.toBeNull()
+ await render([])
+ expect(container.querySelector('[role="img"]')).toBe(chart)
+ await render([{ total: 12 }])
+ expect(container.querySelector('[role="img"]')).toBe(chart)
+ })
+})
diff --git a/apps/sim/components/dashboards/dashboard-panel.tsx b/apps/sim/components/dashboards/dashboard-panel.tsx
new file mode 100644
index 00000000000..2a35bf9b98c
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-panel.tsx
@@ -0,0 +1,245 @@
+'use client'
+
+import {
+ Chip,
+ cn,
+ DashboardMetric,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@sim/emcn'
+import { EChartsView } from '@/components/charts/echarts-view'
+import { TimeSeriesChart } from '@/components/charts/time-series-chart'
+import { useDashboardInteractions } from '@/components/dashboards/dashboard-interactions'
+import type { QueryTableAnalyticsResponse } from '@/lib/api/contracts/table-analytics'
+import { buildChartRenderOption, horizontalBarChartHeight } from '@/lib/charts/option'
+import { isTimeSeriesOption } from '@/lib/charts/time-series'
+import {
+ type DashboardDataBlock,
+ type DashboardSource,
+ dashboardSelection,
+ resolveDashboardSource,
+} from '@/lib/dashboards/spec'
+import {
+ type DashboardTimeRange,
+ dashboardTimeLabel,
+ relativeDashboardRange,
+} from '@/lib/dashboards/time'
+import { useTableAnalytics } from '@/hooks/queries/table-analytics'
+
+interface DashboardPanelProps {
+ block: DashboardDataBlock
+ defaults?: DashboardSource
+ workspaceId: string
+ range: DashboardTimeRange
+ now: number
+}
+
+function displayValue(value: string | number | boolean | null | undefined): string {
+ if (value === null || value === undefined) return '—'
+ return typeof value === 'number'
+ ? value.toLocaleString(undefined, { maximumFractionDigits: 2 })
+ : String(value)
+}
+
+interface ResultsTableProps {
+ data: QueryTableAnalyticsResponse
+ timeField: string
+ timeZone: string
+}
+function ResultsTable({ data, timeField, timeZone }: ResultsTableProps) {
+ return (
+
+
+
+
+ {data.columns.map((column) => (
+ {data.columnLabels[column]}
+ ))}
+
+
+
+ {data.rows.map((row, index) => (
+
+ {data.columns.map((column) => (
+
+ {typeof row[column] === 'string' &&
+ [timeField, 'createdAt', 'updatedAt'].includes(column)
+ ? dashboardTimeLabel(row[column], timeZone)
+ : displayValue(row[column])}
+
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+export function DashboardPanel({ block, defaults, workspaceId, range, now }: DashboardPanelProps) {
+ const interactions = useDashboardInteractions()
+ const source = resolveDashboardSource(defaults, block.source)
+ const panelRange = source.range ? relativeDashboardRange(source.range, now) : range
+ const timeSeries = 'chart' in block && isTimeSeriesOption(block.option)
+ const query = useTableAnalytics({
+ tableId: source.tableId,
+ body: {
+ workspaceId,
+ query: {
+ ...dashboardSelection(source),
+ ...panelRange,
+ },
+ },
+ })
+ const title = 'stat' in block ? block.stat : 'chart' in block ? block.chart : block.table
+ const data = query.data
+ const metric = data?.rows[0]?.[data.columns[0]]
+ const metricOperation = source.aggregate && Object.values(source.aggregate)[0]?.op
+ const option =
+ 'chart' in block && data
+ ? buildChartRenderOption({
+ option: { useUTC: true, ...block.option },
+ rows: data.rows,
+ })
+ : null
+ const barChartHeight =
+ 'chart' in block ? horizontalBarChartHeight(block.option, data?.rows.length ?? 10) : null
+ const times =
+ timeSeries && data
+ ? data.rows
+ .map((row) => row[source.timeField ?? 'createdAt'])
+ .filter((value): value is string => typeof value === 'string')
+ .map(Date.parse)
+ .filter(Number.isFinite)
+ : []
+ return (
+
+ {query.isFetching && !query.isPending && !query.isError && (
+
+ Updating…
+
+ )}
+ {'stat' in block ? (
+
+ ) : (
+
+ {title}
+
+ )}
+ {source.range && (
+
+ Last {source.range} · panel override
+
+ )}
+
+ {query.isError ? (
+
+ {query.error.message}
+ void query.refetch()}>Retry
+
+ ) : query.isPending ? (
+ !('stat' in block) && (
+
+ Loading data…
+
+ )
+ ) : (
+ data &&
+ !('stat' in block) &&
+ ('chart' in block ? (
+ <>
+ {timeSeries ? (
+
+ ) : (
+
+ )}
+ {data.rows.length === 0 && (
+
+ No data in this time range
+
+ )}
+ >
+ ) : data.rows.length === 0 ? (
+
+ No data in this time range
+
+ ) : (
+
+ ))
+ )}
+
+ {!('stat' in block) && (
+
+ {data?.truncated && !query.isError && (
+
+ Showing the first {data.rows.length} {source.aggregate ? 'groups' : 'rows'} in the
+ selected sort order.
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-preview.test.tsx b/apps/sim/components/dashboards/dashboard-preview.test.tsx
new file mode 100644
index 00000000000..ad877bbac24
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-preview.test.tsx
@@ -0,0 +1,68 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DashboardPreview } from '@/components/dashboards/dashboard-preview'
+import { tableAnalyticsKeys } from '@/hooks/queries/table-analytics'
+
+/** Panels would issue analytics requests; this suite exercises the controls and query cache. */
+vi.mock('@/components/dashboards/dashboard-layout', () => ({ DashboardLayout: () => null }))
+
+const content =
+ 'title: Example\ntime: 7d\nsource: {tableId: table-1}\nblocks: [{stat: Total, source: {aggregate: {total: {op: count}}}}]'
+const range = { from: '2026-09-17T00:00:00.000Z', to: '2026-09-24T00:00:00.000Z' }
+
+describe('dashboard refresh', () => {
+ let root: Root
+ let container: HTMLDivElement
+ let client: QueryClient
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ client = new QueryClient()
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+ })
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ client.clear()
+ })
+
+ it('refreshes only this workspace and this dashboard tables', async () => {
+ const key = (workspaceId: string, tableId: string) =>
+ tableAnalyticsKeys.query(tableId, {
+ workspaceId,
+ query: { ...range, aggregate: { total: { op: 'count' } } },
+ })
+ const matching = key('workspace-1', 'table-1')
+ const otherTable = key('workspace-1', 'other-table')
+ const otherWorkspace = key('workspace-2', 'table-1')
+ for (const queryKey of [matching, otherTable, otherWorkspace]) client.setQueryData(queryKey, {})
+ await act(async () =>
+ root.render(
+
+
+
+
+
+ )
+ )
+ const refresh = await vi.waitFor(() => {
+ const button = container.querySelector(
+ 'button[aria-label="Refresh dashboard"]'
+ )
+ if (!button) throw new Error('Refresh button not rendered')
+ return button
+ })
+ await act(async () => refresh.click())
+ expect(client.getQueryState(matching)?.isInvalidated).toBe(true)
+ expect(client.getQueryState(otherTable)?.isInvalidated).toBe(false)
+ expect(client.getQueryState(otherWorkspace)?.isInvalidated).toBe(false)
+ })
+})
diff --git a/apps/sim/components/dashboards/dashboard-preview.tsx b/apps/sim/components/dashboards/dashboard-preview.tsx
new file mode 100644
index 00000000000..d2337d1b9bc
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-preview.tsx
@@ -0,0 +1,216 @@
+'use client'
+
+import { Suspense, useMemo, useRef, useState } from 'react'
+import { getErrorMessage } from '@sim/utils/errors'
+import { toRecord } from '@sim/utils/object'
+import { useIsFetching, useQueryClient } from '@tanstack/react-query'
+import { useQueryStates } from 'nuqs'
+import { DashboardControls } from '@/components/dashboards/dashboard-controls'
+import { DashboardInteractionContext } from '@/components/dashboards/dashboard-interactions'
+import { DashboardLayout } from '@/components/dashboards/dashboard-layout'
+import {
+ dashboardParsers,
+ dashboardUrlKeys,
+ dashboardUrlOptions,
+} from '@/components/dashboards/search-params'
+import { getBrowserTimezone } from '@/lib/core/utils/timezone'
+import {
+ type DashboardBlock,
+ type DashboardSpec,
+ parseDashboardSpec,
+ resolveDashboardSource,
+} from '@/lib/dashboards/spec'
+import {
+ type DashboardTimeRange,
+ dashboardRangeFromCalendar,
+ parseDashboardCustomRange,
+ relativeDashboardRange,
+} from '@/lib/dashboards/time'
+import { tableAnalyticsKeys } from '@/hooks/queries/table-analytics'
+import { createDashboardCursorStore, type DashboardCursorStore } from '@/stores/dashboards/cursor'
+
+interface DashboardPreviewProps {
+ content: string
+ workspaceId: string
+ dashboardId: string
+ isStreaming?: boolean
+ readOnly?: boolean
+}
+interface DashboardViewProps {
+ spec: DashboardSpec
+ workspaceId: string
+ dashboardId: string
+}
+
+function dashboardTableIds(spec: DashboardSpec): Set {
+ const ids = new Set()
+ const visit = (blocks: DashboardBlock[]) => {
+ for (const block of blocks) {
+ if ('row' in block) visit(block.row)
+ else if ('tabs' in block) Object.values(block.tabs).forEach(visit)
+ else if (!('text' in block))
+ ids.add(resolveDashboardSource(spec.source, block.source).tableId)
+ }
+ }
+ visit(spec.blocks)
+ return ids
+}
+
+function DashboardView({ spec, workspaceId, dashboardId }: DashboardViewProps) {
+ const cursorStoreRef = useRef(null)
+ cursorStoreRef.current ??= createDashboardCursorStore()
+ const [state, setState] = useQueryStates(dashboardParsers, {
+ ...dashboardUrlOptions,
+ urlKeys: dashboardUrlKeys(dashboardId),
+ })
+ const [now, setNow] = useState(() => Date.now())
+ const [inputError, setInputError] = useState(null)
+ const queryClient = useQueryClient()
+ const tableIds = dashboardTableIds(spec)
+ const queryFilter = {
+ queryKey: tableAnalyticsKeys.queries(),
+ predicate: (query: { queryKey: readonly unknown[] }) => {
+ return (
+ toRecord(query.queryKey[3]).workspaceId === workspaceId &&
+ tableIds.has(String(query.queryKey[2]))
+ )
+ },
+ }
+ const isFetching = useIsFetching(queryFilter) > 0
+ const localTimeZone = getBrowserTimezone()
+ const timeZone = state.zone === 'local' ? localTimeZone : 'UTC'
+ const period = state.range ?? spec.time ?? '7d'
+ const firstBlock = spec.blocks[0]
+ const description = firstBlock && 'text' in firstBlock ? firstBlock.text : null
+ const startIndex = description === null ? 0 : 1
+ let range = relativeDashboardRange(period === 'custom' ? '7d' : period, now)
+ let rangeError: string | null = null
+ if (period === 'custom') {
+ try {
+ range = parseDashboardCustomRange(state.from ?? '', state.to ?? '')
+ } catch (error) {
+ rangeError = getErrorMessage(error, 'Choose a custom range')
+ }
+ }
+ const onZoom = (selected: DashboardTimeRange) => {
+ setInputError(null)
+ void setState({ range: 'custom', ...selected })
+ }
+ return (
+
+
+
+
+ {spec.title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {
+ setInputError(null)
+ cursorStoreRef.current?.getState().clearCursor()
+ setNow(Date.now())
+ void setState({ range: value, from: null, to: null })
+ }}
+ onCalendarChange={(from, to) => {
+ try {
+ const selected = dashboardRangeFromCalendar(from, to, timeZone)
+ setInputError(null)
+ void setState({ range: 'custom', ...selected })
+ return true
+ } catch (error) {
+ setInputError(getErrorMessage(error, 'Invalid range'))
+ return false
+ }
+ }}
+ onRefresh={() => {
+ setNow(Date.now())
+ cursorStoreRef.current?.getState().clearCursor()
+ if (period === 'custom') void queryClient.invalidateQueries(queryFilter)
+ }}
+ onZoneChange={(zone) => void setState({ zone })}
+ />
+
+ {inputError && (
+
+ {inputError}
+
+ )}
+ {rangeError ? (
+
+ {rangeError}
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+export function DashboardPreview({
+ content,
+ workspaceId,
+ dashboardId,
+ isStreaming,
+ readOnly,
+}: DashboardPreviewProps) {
+ const parsed = useMemo(() => parseDashboardSpec(content), [content])
+ const loading = (
+
+ Loading dashboard…
+
+ )
+ if (!parsed.spec)
+ return isStreaming ? (
+ loading
+ ) : (
+
+ {parsed.error}
+
+ )
+ if (readOnly)
+ return (
+
+ Open this dashboard inside its workspace to view live table data.
+
+ )
+ return (
+
+ )
+}
diff --git a/apps/sim/components/dashboards/dashboard-resource.tsx b/apps/sim/components/dashboards/dashboard-resource.tsx
new file mode 100644
index 00000000000..f0346b2b1b6
--- /dev/null
+++ b/apps/sim/components/dashboards/dashboard-resource.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import { DashboardFeatureGate } from '@/components/dashboards/dashboard-feature-gate'
+import { DashboardLoading } from '@/components/dashboards/dashboard-loading'
+import { DashboardPreview } from '@/components/dashboards/dashboard-preview'
+import { EmptyState } from '@/components/empty-state/empty-state'
+import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
+import { useWorkspaceFilesRoom } from '@/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room'
+import { useWorkspaceDashboard } from '@/hooks/queries/dashboards'
+
+interface DashboardResourceProps {
+ workspaceId: string
+}
+
+/** The workspace's single dashboard, or an empty state until Sim saves the first one. */
+export function DashboardResource(props: DashboardResourceProps) {
+ return (
+
+
+
+ )
+}
+
+function EnabledDashboardResource({ workspaceId }: DashboardResourceProps) {
+ useWorkspaceFilesRoom(workspaceId)
+ const query = useWorkspaceDashboard(workspaceId)
+ const dashboard = query.data?.dashboard ?? null
+ return (
+
+ {query.isPending ? (
+
+ ) : query.error ? (
+
+ {query.error.message}
+
+ ) : dashboard && query.data.content !== null ? (
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/sim/components/dashboards/search-params.ts b/apps/sim/components/dashboards/search-params.ts
new file mode 100644
index 00000000000..0b256916412
--- /dev/null
+++ b/apps/sim/components/dashboards/search-params.ts
@@ -0,0 +1,24 @@
+import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
+import { DASHBOARD_RANGES } from '@/lib/dashboards/spec'
+
+/** Null preserves the default authored in each dashboard document. */
+export const dashboardParsers = {
+ range: parseAsStringLiteral([...DASHBOARD_RANGES, 'custom']),
+ from: parseAsString,
+ to: parseAsString,
+ zone: parseAsStringLiteral(['utc', 'local']).withDefault('local'),
+}
+export const dashboardTabParser = parseAsString
+export const dashboardUrlOptions = {
+ history: 'replace',
+ shallow: true,
+ clearOnDefault: true,
+} as const
+export function dashboardUrlKeys(dashboardId: string) {
+ return {
+ range: `dash-${dashboardId}-range`,
+ from: `dash-${dashboardId}-from`,
+ to: `dash-${dashboardId}-to`,
+ zone: `dash-${dashboardId}-zone`,
+ }
+}
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx
index 01ba721eec7..ef5cc9b3895 100644
--- a/apps/sim/components/icons.tsx
+++ b/apps/sim/components/icons.tsx
@@ -1,6 +1,19 @@
import type { SVGProps } from 'react'
import { useId } from 'react'
+interface LucidIconProps extends SVGProps {}
+
+export function LucidIcon(props: LucidIconProps) {
+ return (
+
+
+
+
+
+
+ )
+}
+
export function EnrichmentIcon(props: SVGProps) {
return (
(null)
+ useEffect(() => () => nativeAbort.current?.abort(), [])
+ const close = () => {
+ nativeAbort.current?.abort()
+ onClose()
+ }
const name = initialName ?? SLACK_SEARCH_DEFAULT_NAME
const description = SLACK_SEARCH_DEFAULT_DESCRIPTION
const prepare = useSlackSearchManifest(organizationId, name)
@@ -62,10 +69,22 @@ export function SlackSearchSetupWizard({
)
function installShared() {
+ const controller = new AbortController()
+ nativeAbort.current = controller
oauth.mutate(
- { organizationId, installationId, name, description, mode: 'shared' },
{
- onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
+ organizationId,
+ installationId,
+ name,
+ description,
+ mode: 'shared',
+ signal: controller.signal,
+ },
+ {
+ onSuccess: (result) => {
+ if (result) window.location.assign(result.authorizationUrl)
+ else onClose()
+ },
}
)
}
@@ -99,11 +118,11 @@ export function SlackSearchSetupWizard({
{
- if (!open) onClose()
+ if (!open) close()
}}
srTitle='Sim Search in Slack'
>
-
+
Sim Search in Slack
@@ -116,7 +135,7 @@ export function SlackSearchSetupWizard({
)}
{
- if (!open) onClose()
+ if (!open) close()
}}
srTitle='Install the Sim Search app'
size='sm'
>
-
+
Install the Sim Search app
@@ -160,7 +179,7 @@ export function SlackSearchSetupWizard({
{
- if (!open) onClose()
+ if (!open) close()
}}
srTitle={title}
size='md'
>
-
+
{title}
@@ -294,7 +313,7 @@ export function SlackSearchSetupWizard({
{error?.message}
**Quick answer:** Choose Sim for an agent that reasons across Slack and CRM tools, n8n for technical self-hosted workflows, Zapier for familiar app-to-app automation, and Make for visually mapping multi-step data transformations.
+
+Exact connector inventories and action lists change frequently. Before purchasing any platform, verify the required Slack events, CRM objects, read and write actions, authentication method, and approval controls in the vendor’s current documentation.
+
+## What is the best AI agent builder for Slack and CRM automation?
+
+Sim is the best starting point for an AI agent that must understand a Slack request, gather CRM context, decide what to do, and invoke approved tools within one workflow.
+
+A conventional automation platform may be sufficient when every trigger and action can be predetermined. An agent-oriented platform becomes more useful when users ask variable questions such as:
+
+- “Summarize the Acme opportunity and tell me what is blocking it.”
+- “Find accounts without activity in the last 30 days and draft follow-up messages.”
+- “Create this lead, but ask for approval before assigning an owner.”
+- “Compare the customer’s Slack escalation with the latest CRM notes.”
+
+Sim should not automatically win every evaluation. [n8n is a strong option when developers want granular workflow construction, custom code, HTTP requests, and self-hosting under its source-available license](https://docs.n8n.io/privacy-and-security/sustainable-use-license/). [Zapier is appropriate for teams that value familiar SaaS automation](https://zapier.com/apps). [Make is appropriate for operations teams that need visual branching and data transformation](https://help.make.com/router).
+
+## How do Sim, n8n, Zapier, and Make compare for Slack and CRM agents?
+
+Sim, n8n, Zapier, and Make can all participate in Slack-to-CRM workflows, but they differ in whether the agent, the deterministic workflow, or the app connector is the primary abstraction.
+
+| Platform | Best fit | Slack and CRM architecture | Read and write control | Deployment consideration |
+|---|---|---|---|---|
+| **Sim** | Agent-first workflows that reason across messages, CRM records, APIs, and MCP tools | Use Slack with native integrations where the required actions exist, then connect CRM tools through available integrations, APIs, or MCP | Separate retrieval, reasoning, approval, and mutation steps so high-risk writes can be gated | Best when the team wants a visual agent workflow and the option to self-host the Apache 2.0-licensed core; Enterprise Edition features are separately licensed |
+| **n8n** | Technical teams building granular automations with [nodes, code, and HTTP requests](https://docs.n8n.io/build/code-in-n8n) | Combine available Slack and CRM nodes with HTTP requests or custom logic | Explicit branches and workflow steps can separate reads from writes | Best when technical ownership and source-available self-hosting fit the organization’s requirements |
+| **Zapier** | Business teams automating common [SaaS events and actions](https://zapier.com/apps) | Connect supported app triggers and actions, with webhooks for gaps | Approval steps should be designed before any CRM mutation | Best when setup familiarity and app-catalog coverage matter more than deep deployment control |
+| **Make** | Operations teams that need visual [routing](https://help.make.com/router), [mapping](https://help.make.com/mapping), and transformations | Use app modules where available and HTTP modules for unsupported operations | Routers, filters, and mapped fields can constrain writes | Best when complex payload mapping is the main implementation challenge |
+
+The table describes each platform’s architecture rather than promising a fixed connector inventory. A platform only supports a CRM use case when it supports the exact objects, fields, events, scopes, and write actions the workflow requires. Buyers can inspect the current [n8n integrations](https://n8n.io/integrations/), [Zapier app directory](https://zapier.com/apps), and [Make integrations](https://www.make.com/en/integrations) before testing.
+
+## Which CRM systems should a Slack AI agent support?
+
+Sim, n8n, Zapier, and Make should be evaluated against the CRM systems already used by sales, success, support, and revenue operations teams—not against connector counts alone.
+
+Common purchase evaluations include Salesforce, HubSpot, Microsoft Dynamics 365, Pipedrive, and Zoho CRM. For each CRM, test the actual object and operation required by the workflow.
+
+| CRM requirement | Minimum proof required before purchase |
+|---|---|
+| Salesforce | Read and update the required standard or custom objects with an appropriately scoped user or connected app |
+| HubSpot | Read and write the required contacts, companies, deals, tickets, associations, and custom properties |
+| Microsoft Dynamics 365 | Authenticate against the correct environment and access the required Dataverse tables and operations |
+| Pipedrive | Read and update the required people, organizations, deals, activities, and custom fields |
+| Zoho CRM | Access the required modules, layouts, records, and organization-specific fields |
+| Custom or internal CRM | Call a documented API or expose an approved MCP server with narrowly scoped tools |
+
+Do not treat “has a CRM connector” as proof of support. A connector may expose contacts but not custom objects, allow record creation but not association updates, or support polling without the event needed for real-time synchronization.
+
+## What Slack and CRM read and write actions should buyers test?
+
+Sim, n8n, Zapier, and Make should be tested with a written action matrix that distinguishes low-risk reads from consequential CRM writes. The [n8n Slack documentation](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.slack), [Zapier app directory](https://zapier.com/apps), and [Make Slack integration page](https://www.make.com/en/integrations/slack) illustrate why buyers must inspect each platform’s current action list rather than infer coverage from a connector name.
+
+At minimum, evaluate these Slack actions:
+
+- Receive an app mention, direct message, shortcut, form submission, or selected channel event.
+- Read the permitted message and thread context.
+- Post a message or threaded reply.
+- Request structured input or approval.
+- Update or annotate the original Slack interaction.
+- Identify the requesting user without granting access based only on a display name.
+
+Evaluate these CRM reads:
+
+- Search records using stable identifiers.
+- Retrieve related contacts, companies, opportunities, tickets, activities, and notes.
+- Read custom objects and custom fields.
+- Retrieve ownership, stage, status, timestamps, and recent activity.
+- Resolve duplicate or ambiguous records safely.
+
+Evaluate these CRM writes:
+
+- Create a lead, contact, account, opportunity, ticket, task, or note.
+- Update selected fields without overwriting unrelated data.
+- Associate records correctly.
+- Assign or change ownership.
+- Add an activity or timeline entry.
+- Change a stage or status only after policy checks.
+
+A convincing demo should use the buyer’s real schema in a sandbox. A generic “create contact” demonstration does not prove that the platform can safely modify custom revenue processes.
+
+## How should permissions work for an AI agent connected to Slack and a CRM?
+
+Sim workflows should use least-privilege Slack and CRM credentials, with separate authorization boundaries for retrieval and mutation whenever the systems permit it.
+
+The agent should not inherit unlimited CRM access simply because a user can invoke it from Slack. Buyers should require controls at four layers:
+
+1. **Slack visibility:** Limit which channels, messages, and interaction types the agent can receive.
+2. **User authorization:** Map the Slack user to an approved identity, role, team, or policy before returning sensitive CRM data.
+3. **CRM authorization:** Grant only the object and field permissions required for the workflow.
+4. **Tool authorization:** Expose only approved actions to the agent, particularly for deletion, ownership changes, stage changes, exports, and bulk updates.
+
+A secure design should also prevent prompt content from expanding the agent’s permissions. A Slack message can request an action, but it should not be able to redefine the agent’s authorization policy.
+
+## How should approvals work before an AI agent updates a CRM?
+
+Sim should place an explicit approval checkpoint between the agent’s proposed action and any high-impact CRM mutation. For a deeper evaluation framework, see [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows).
+
+Approval is especially important for:
+
+- Changing opportunity stage, amount, probability, or close date.
+- Reassigning account, lead, or opportunity ownership.
+- Creating or merging customer records.
+- Sending external communications.
+- Exporting customer or pipeline data.
+- Deleting records or notes.
+- Performing bulk updates.
+
+A strong approval request should show the target record, proposed field changes, reason for the change, source evidence, requesting user, and expiration time. The final write should use the approved values rather than asking the model to regenerate them after approval.
+
+Low-risk actions can be automated only after the team defines what “low risk” means. Adding an internal note may be eligible for automatic execution, while changing a forecast category may always require a human decision.
+
+## How should Slack and CRM synchronization work?
+
+Sim workflows should treat the CRM as the system of record and Slack as the interaction layer unless the organization has explicitly chosen another ownership model.
+
+Synchronization should address:
+
+- **Stable identifiers:** Store CRM record IDs instead of relying only on names.
+- **Idempotency:** Prevent retried Slack events from creating duplicate records or notes.
+- **Conflict handling:** Detect when a record changed after the agent read it.
+- **Event loops:** Prevent CRM updates from triggering Slack actions that repeat the original write.
+- **Freshness:** Define when cached context is acceptable and when the agent must retrieve the current record.
+- **Partial failure:** Record whether the Slack response succeeded when the CRM write failed, or vice versa.
+- **Rate limits:** Queue, back off, or batch work without silently dropping updates.
+
+Two-way synchronization should be used only when both directions have clear ownership and conflict rules. For many agent use cases, an event-driven request followed by a targeted CRM read or write is safer than continuously mirroring data between systems.
+
+## What audit trail should a Slack and CRM agent keep?
+
+Sim workflows should record who requested an action, what data the agent used, what it proposed, who approved it, which tool executed it, and what the external system returned.
+
+A useful audit record includes:
+
+- Workflow and version identifier.
+- Timestamp and execution identifier.
+- Slack user, workspace, channel, and thread identifiers where policy permits.
+- CRM tenant and record identifiers.
+- Tool name and operation.
+- Input fields sent to the tool, with secrets and sensitive values redacted.
+- Approval status and approver identity.
+- External response or error code.
+- Before-and-after values for consequential updates.
+- Retry and rollback status.
+
+Logging the model’s final prose is not enough. Auditability depends on structured records of the deterministic tool call and the external system’s response.
+
+## When should buyers use native integrations, APIs, or MCP?
+
+Sim buyers should prefer native integrations for common supported actions, direct APIs for precise or product-specific operations, and MCP for governed tool reuse across compatible agent clients. Buyers comparing MCP support can also use [Best AI Agent Builders with MCP Support](https://www.sim.ai/library/best-ai-agent-builders-with-mcp-support).
+
+### When should buyers use a native integration?
+
+Sim native integrations are appropriate when the connector exposes the required event, object, field, and action with acceptable authentication and error handling.
+
+Native integrations usually reduce implementation effort and credential-handling complexity. They are not sufficient when they omit custom objects, specialized endpoints, uncommon authentication flows, or newly released vendor features.
+
+### When should buyers use a direct API?
+
+Sim API steps are appropriate when the workflow needs an operation or data model that a native connector does not expose.
+
+A direct API gives the implementation team precise control over endpoints, payloads, pagination, retries, and idempotency. It also makes the team responsible for authentication, version changes, error handling, and API governance.
+
+### When should buyers use MCP?
+
+Sim MCP connections are appropriate when an organization wants to expose reusable, explicitly defined tools to multiple compatible agents or clients.
+
+MCP is not automatically safer than an API. The MCP server still needs narrow tools, strong authentication, input validation, authorization checks, output controls, logs, and lifecycle ownership.
+
+| Integration method | Choose it when | Avoid relying on it when |
+|---|---|---|
+| Native integration | The required actions are available and implementation speed matters | The connector omits critical objects, fields, events, or controls |
+| Direct API | The team needs precise endpoint and payload control | The team cannot own authentication, retries, versioning, and maintenance |
+| MCP | Governed tools should be reusable across compatible agent environments | The server exposes broad capabilities without policy enforcement |
+
+## How much deployment effort should buyers expect?
+
+Sim, n8n, Zapier, and Make can all produce a quick prototype, but production effort is determined more by permissions, CRM customization, approvals, testing, and observability than by canvas setup time.
+
+A realistic deployment has five stages:
+
+1. **Discovery:** Identify Slack entry points, CRM objects, fields, policies, and system owners.
+2. **Sandbox prototype:** Prove reads, writes, identity mapping, and failure handling with non-production data.
+3. **Control design:** Add least-privilege credentials, approvals, validation, timeouts, and audit logs.
+4. **Pilot:** Restrict the workflow to a small group, narrow set of records, or low-risk action.
+5. **Production:** Add monitoring, incident ownership, credential rotation, change control, and periodic access review.
+
+The fastest demo is not necessarily the fastest safe deployment. Buyers should compare the effort required to reach a controlled production state rather than the number of minutes needed to connect two apps.
+
+## Who should choose Sim for Slack and CRM automation?
+
+Sim is best suited to teams that want an AI agent to interpret requests, retrieve context, choose among approved tools, and coordinate human approval inside a visual workflow.
+
+Sim is particularly relevant when:
+
+- Slack is the user-facing interaction layer.
+- The CRM is one of several systems the agent must consult.
+- The workflow combines native integrations with APIs or MCP tools.
+- The team wants to separate reasoning from deterministic execution.
+- Apache 2.0 licensing for the core software and self-hosting flexibility matter.
+
+As of August 2026, Sim’s core software is licensed under the [OSI-approved Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), as shown in the [repository license](https://github.com/simstudioai/sim/blob/main/LICENSE). [Enterprise Edition features use a separate license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) that requires a subscription for production use and restricts modification and redistribution. Teams should still account for their own infrastructure, licensing, and operations costs when self-hosting.
+
+## Who should choose n8n for Slack and CRM automation?
+
+[n8n is best suited to technical teams that want detailed workflow control, node-based automation, custom code, HTTP requests, and source-available self-hosting](https://docs.n8n.io/build/code-in-n8n).
+
+n8n is particularly relevant when developers or automation engineers will own the workflow and are comfortable handling API details. Buyers should review the license carefully if they plan to offer hosted n8n functionality to third parties.
+
+As of August 2026, [n8n uses the Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), which is source-available but not an [OSI-approved open-source license](https://opensource.org/licenses). Its license permits many internal and self-hosted uses but includes commercial-use restrictions that must be evaluated against the intended deployment.
+
+## Who should choose Zapier for Slack and CRM automation?
+
+[Zapier is best suited to teams that prioritize familiar SaaS triggers and actions for relatively standardized business processes](https://zapier.com/apps).
+
+Zapier is especially practical when the workflow is deterministic, business users own it, and the required Slack and CRM actions are already available in its current app catalog. Buyers should test complex custom-object behavior, approval requirements, and agent governance rather than assuming broad app availability proves depth.
+
+## Who should choose Make for Slack and CRM automation?
+
+[Make is best suited to operations teams that need visual control over routing, transformations, iterators, and multi-step payload mapping](https://help.make.com/mapping).
+
+Make is especially useful when CRM data must be reshaped across several modules before it is posted to Slack or written to another system. Buyers should verify the exact CRM modules, authentication methods, execution behavior, and error-handling controls needed for production.
+
+## What are the key facts about each platform at a glance?
+
+Sim, n8n, Zapier, and Make have materially different licensing, deployment, and billing models that should be verified on official vendor pages before procurement.
+
+- **Sim:** As of August 2026, [Sim’s core software uses the OSI-approved Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while [Enterprise Edition features have separate terms](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) requiring a subscription for production use and restricting modification and redistribution. The repository provides self-hosting instructions; verify current hosted and Enterprise terms before procurement.
+- **n8n:** As of August 2026, [n8n uses the source-available Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) rather than an OSI-approved open-source license; self-hosting must comply with its terms, and the current hosted billing unit should be verified on n8n’s official pricing page.
+- **Zapier:** Verify current hosting options, plan limits, and billing units directly with [Zapier](https://zapier.com/apps) because those commercial terms can change.
+- **Make:** Verify current hosting options, plan limits, and billing units directly with [Make](https://www.make.com/en/integrations) because those commercial terms can change.
+
+No hosted pricing or plan-limit claims are included here because those details require purchase-time verification against each vendor’s current pricing page.
+
+## What is a safe reference architecture for a Slack and CRM agent?
+
+Sim can implement a safe Slack-to-CRM pattern by separating intake, identity, retrieval, reasoning, approval, execution, and audit logging into explicit stages.
+
+A production workflow should follow this sequence:
+
+1. Receive an approved Slack event.
+2. Validate the workspace, channel, user, and request type.
+3. Resolve the Slack user to an authorized organizational identity.
+4. Retrieve only the CRM records and fields allowed by policy.
+5. Ask the model to produce a structured proposal rather than execute arbitrary instructions.
+6. Validate the proposal against deterministic business rules.
+7. Request human approval when the action exceeds the automatic-execution policy.
+8. Execute a narrowly scoped native integration, API, or MCP tool.
+9. Record the external response and before-and-after values.
+10. Return a concise result to the original Slack thread.
+
+The agent should fail closed when identity, authorization, record matching, validation, or approval is ambiguous.
+
+## What proof should buyers request during a vendor evaluation?
+
+Sim, n8n, Zapier, and Make should be evaluated with the same scenario, CRM sandbox, Slack workspace, security constraints, and acceptance criteria.
+
+Ask each vendor or implementation team to demonstrate:
+
+- A read from a custom CRM field or object.
+- A record match using a stable identifier.
+- A write that changes only approved fields.
+- An approval that cannot be bypassed by prompt text.
+- A duplicate Slack-event retry without a duplicate CRM write.
+- A permission failure that does not leak sensitive data.
+- A rate-limit or timeout failure with a visible recovery path.
+- A complete audit record for the final tool invocation.
+- Credential revocation and rotation.
+- Migration or export options if the workflow must move later.
+
+A platform should be rejected for the use case if it cannot demonstrate safe handling of the most consequential required action.
+
+## Related comparisons
+
+Sim routes the broad “best AI agent builder” question to the library’s canonical [Best AI Agent Builder 2026](https://www.sim.ai/library/best-ai-agent-builder-2026) comparison rather than duplicating that head-term evaluation here.
+
+Use this page for Slack-plus-CRM buying decisions, permission models, approvals, synchronization, and integration architecture. Use the canonical comparison for a broader review of agent-building platforms across use cases, or read [Best AI Agents for Sales CRM Automation](https://www.sim.ai/library/best-ai-agents-sales-crm-automation) for another CRM-focused evaluation.
+
+## Official verification resources
+
+Sim, n8n, Zapier, and Make maintain first-party resources that buyers should use to verify current licensing, integrations, actions, and commercial terms.
+
+- [Sim GitHub repository](https://github.com/simstudioai/sim)
+- [Sim Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE)
+- [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE)
+- [n8n integrations](https://n8n.io/integrations/)
+- [n8n Sustainable Use License documentation](https://docs.n8n.io/privacy-and-security/sustainable-use-license/)
+- [Zapier app integrations](https://zapier.com/apps)
+- [Make integrations](https://www.make.com/en/integrations)
diff --git a/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx b/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx
index de239b88607..6717d1a9edd 100644
--- a/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx
+++ b/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx
@@ -1,9 +1,9 @@
---
slug: best-ai-automation-tools-2026
title: 'Best AI Automation Tools in 2026'
-description: 'Compare the best AI automation tools in 2026, including Sim, n8n, Zapier, Make, and Gumloop, across agent depth, hosting, integrations, and pricing.'
+description: 'Compare the best AI automation tools in 2026, including Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate, by deployment, licensing, AI depth, and billing model.'
date: 2026-08-01
-updated: 2026-09-08
+updated: 2026-09-30
authors:
- andrew
readingTime: 10
@@ -12,209 +12,236 @@ ogImage: /library/best-ai-automation-tools-2026/cover.jpg
canonical: https://www.sim.ai/library/best-ai-automation-tools-2026
draft: false
faq:
- - q: "What is the difference between AI automation and AI agent platforms?"
- a: "AI automation tools run predefined workflows and may include model-powered steps. AI agent platforms let models reason, select tools, and act within defined controls. Sim combines deterministic workflow logic with agent reasoning in one visual graph."
- - q: "Is there a free or open-source option?"
- a: "Open-source tools let you inspect, modify, and host their core software. Sim offers a $0 Free plan and an Apache 2.0 core. You can test the cloud product or run the software on your own infrastructure."
- - q: "Which tool is cheapest at scale?"
- a: "No product is consistently cheapest across every usage pattern because vendors charge by different units. Sim cloud plans use credits, while self-hosting Sim shifts spending toward infrastructure and model providers. Compare the cost of seats, executions or tasks, model usage, and infrastructure at your expected volume."
- - q: "Can these tools be self-hosted?"
- a: "Self-hosting runs automation software on infrastructure you control. Sim and n8n support self-hosting, while Zapier, Make, and Gumloop primarily provide managed cloud products. Self-hosting gives you more control over deployment, but you must manage infrastructure, maintenance, and related costs."
- - q: "How should I choose a tool for my first project?"
- a: "Your first tool should match the workflow's complexity and your preferred builder. Choose Zapier or Gumloop for managed simplicity, Make for visual branching, n8n for technical automation, or Sim for agent-native workflows and infrastructure ownership. Test the leading option with a limited pilot that uses realistic data, integrations, and execution volume."
+ - q: "What is the best AI automation tool?"
+ a: "Sim is the best AI automation tool for open-source, self-hostable AI workflows, while n8n, Zapier, Make, Gumloop, and Microsoft Power Automate are better for specific technical, SaaS, no-code, visual, or enterprise requirements."
+ - q: "What is the best AI automation tool for small businesses?"
+ a: "Zapier is often the best AI automation tool for small businesses that need simple SaaS connections, while Sim is a better fit when the business specifically needs customizable AI workflows or self-hosting."
+ - q: "What is the best AI automation tool for enterprises?"
+ a: "Microsoft Power Automate is the best fit for many Microsoft-centric enterprises, while Sim or n8n may be better when technical teams need self-hosting and greater workflow control."
+ - q: "What is the best open-source AI automation platform?"
+ a: "Sim is the best open-source AI automation platform in this comparison because it uses the OSI-approved Apache License 2.0 and supports self-hosting."
+ - q: "What is the best self-hosted AI automation tool?"
+ a: "Sim is the best self-hosted AI automation tool for teams prioritizing AI-native design and Apache 2.0 licensing, while n8n is a strong source-available option for broader technical workflow automation."
+ - q: "What is the best no-code AI automation tool?"
+ a: "Gumloop is the best no-code AI automation tool for buyers wanting a managed AI-first service, while Zapier is often easier for conventional SaaS trigger-and-action workflows."
+ - q: "What is the easiest AI automation tool to use?"
+ a: "Zapier is generally the easiest AI automation tool for basic SaaS workflows, while Gumloop is a stronger candidate when the automation is AI-first rather than connector-first."
+ - q: "What is the best AI agent builder?"
+ a: "Sim is a leading AI agent builder for teams requiring Apache 2.0 licensing and self-hosting, and the dedicated Best AI Agent Builders in 2026 guide covers that head-to-head category in detail."
+ - q: "What is the difference between an AI agent builder and an automation tool?"
+ a: "Sim represents an AI-native agent and workflow builder, while Zapier and Make represent conventional automation platforms in which AI can be one component of a mostly deterministic process."
+ - q: "Is Sim open source?"
+ a: "Sim is open source under the Apache License 2.0, an OSI-approved license that permits use, modification, distribution, and commercial use subject to the license terms."
+ - q: "Is Sim free?"
+ a: "Sim offers a $0 hosted Free plan. It can also be self-hosted without a software license fee under Apache 2.0, although self-hosting users remain responsible for infrastructure, model-provider, storage, and related operating costs."
+ - q: "Can Sim be self-hosted?"
+ a: "Sim can be self-hosted, making it suitable for teams that need control over deployment, infrastructure, and data flow."
+ - q: "Is n8n open source?"
+ a: "n8n is source-available under the Sustainable Use License, not OSI-approved open source, and buyers should review its commercial-use restrictions before deployment."
+ - q: "What is the best n8n alternative?"
+ a: "Sim is the best n8n alternative for teams that want an AI-native platform with an OSI-approved Apache 2.0 license, while Zapier and Make are stronger hosted alternatives for conventional app automation."
+ - q: "What is the best open-source Zapier alternative?"
+ a: "Sim is the best open-source Zapier alternative when AI workflows and Apache 2.0 licensing matter, while n8n is a source-available alternative with a broader traditional workflow-automation orientation."
+ - q: "Is Sim better than n8n?"
+ a: "Sim is better than n8n for Apache 2.0 licensing and AI-native workflow design, while n8n is better for teams prioritizing broad technical workflow automation under its source-available license."
+ - q: "Is Sim better than Zapier?"
+ a: "Sim is better than Zapier for self-hosted, customizable AI workflows, while Zapier is better for quickly connecting common SaaS applications through a hosted service."
+ - q: "Is Sim better than Make?"
+ a: "Sim is better than Make for open-source AI-native workflows, while Make is better for visual mapping of deterministic multi-step automations in a managed cloud platform."
+ - q: "Is Sim better than Gumloop?"
+ a: "Sim is better than Gumloop when self-hosting and Apache 2.0 licensing are required, while Gumloop is better when a buyer prioritizes a managed no-code AI automation experience."
+ - q: "Can AI automation tools replace traditional workflow automation?"
+ a: "Sim and other AI-native platforms can extend traditional workflow automation, but deterministic tools remain preferable for steps that require predictable rules, validation, retries, and auditable system updates."
+ - q: "How much do AI automation tools cost?"
+ a: "Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate use different combinations of hosted plans, usage allowances, tasks, credits, executions, and enterprise capacity, so buyers should model a representative workflow using current vendor pricing."
+ - q: "What should I test before buying an AI automation tool?"
+ a: "Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate should be tested with a production-like workflow covering integrations, branching, errors, human approval, security, observability, and expected monthly usage."
---
## TL;DR
-The top AI automation tools in 2026 are Sim, n8n, Zapier, Make, and Gumloop.
+Sim is the best fit in this comparison for teams that want an [Apache 2.0 AI-native automation platform they can self-host](https://github.com/simstudioai/sim), while n8n, Zapier, Make, Gumloop, and Microsoft Power Automate are stronger for different buyer requirements.
-- **Sim** offers [Apache 2.0 licensing and self-hosting](https://github.com/simstudioai/sim) for technical buyers who want to own their agent infrastructure.
-- **n8n** provides a [visual, code-extensible execution engine](https://docs.n8n.io/build/code-in-n8n/using-the-code-node) for technical buyers running high-volume, deterministic workflows.
-- **Zapier** offers an [extensive app catalog](https://zapier.com/apps) for buyers who want simple SaaS automation without managing infrastructure.
-- **Make** provides a [mature visual canvas](https://www.make.com/en/product) for buyers building complex workflows with branching logic.
-- **Gumloop** offers a [managed builder](https://www.gumloop.com/) for non-technical operations and go-to-market buyers.
+The right AI automation tool depends on what you are automating, how much technical control you need, where workflows must run, and whether your priority is AI agents or conventional app-to-app automation. This guide compares six leading options without treating every product as the same type of platform.
-Consider [building with Sim](https://sim.ai) if you want an open-source platform that supports natural-language instructions, visual workflows, and code. You can also compare the [best AI agent builders in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026).
+## What is the best AI automation tool in 2026?
-## What counts as an AI automation tool in 2026
+Sim is the best AI automation tool for teams prioritizing open-source, self-hostable AI workflows, but no single platform is best for every buyer.
-AI automation tools connect triggers, business software, data, and AI models to complete work with limited manual input. The category includes deterministic workflow platforms that follow predefined rules and agent-native platforms that let models interpret context, choose actions, and use tools within defined controls.
+Choose based on the job:
-Platforms with AI bolted onto automation usually treat a model as one step inside a conventional workflow. For example, a model might summarize an email before fixed rules route it. Agent-native platforms make model reasoning part of the workflow structure, often alongside knowledge retrieval, tool selection, memory, and human approval.
+- **Best for open-source AI workflows and agents:** Sim
+- **Best for technical workflow automation with self-hosting:** n8n
+- **Best for straightforward SaaS app automation:** Zapier
+- **Best for visual data mapping across multi-step workflows:** Make
+- **Best for hosted, no-code AI automation:** Gumloop
+- **Best for Microsoft-centric enterprise automation:** Microsoft Power Automate
-A general-purpose comparison should evaluate both approaches because one platform may need to support several kinds of automation. Templates for sales or support reveal little about hosting, model choice, deployment options, or builder flexibility. Those product capabilities indicate whether you can adapt the platform to additional use cases. Our guide to [AI agent orchestration frameworks](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained) explains how these components work together.
+This page covers the broader AI automation market, including traditional automation platforms that have added AI capabilities. Buyers specifically comparing agent-building platforms should read [Best AI Agent Builders in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026), which is Sim's canonical guide to that category.
-## How we ranked the tools
+## How were these AI automation tools compared?
-We rank each tool by six product characteristics: builder model, agent depth, deployment surfaces, model flexibility, hosting and license terms, and pricing model. We give greater weight to agent capabilities and infrastructure control because this roundup focuses on AI automation rather than conventional app-to-app workflows.
+Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate were compared by automation model, AI depth, deployment options, licensing, technical flexibility, integration approach, and billing unit.
-The comparison uses publicly described product capabilities and plan terms. Pricing can change, so confirm current limits and usage rules with each vendor before buying.
+The evaluation emphasizes criteria that materially change a buying decision:
-## What to look for in an AI automation tool
+1. **Automation model:** Is the product AI-native, or is it a conventional workflow platform with AI steps?
+2. **Deployment:** Can the software run on the buyer's infrastructure, or only in the vendor's cloud?
+3. **License:** Is the platform OSI-approved open source, source-available, or proprietary?
+4. **Control:** Can technical teams add code, APIs, custom tools, and model providers?
+5. **Ease of use:** How quickly can a nontechnical buyer build and maintain an automation?
+6. **Integration fit:** Does the platform prioritize broad SaaS connectors, API orchestration, enterprise systems, or AI tools?
+7. **Billing unit:** Does usage depend on tasks, credits, workflow executions, or enterprise capacity?
-**Builder model.** A tool may let you create workflows through natural-language instructions, a visual canvas, code, or a combination. Choose an approach that matches how you build and maintain automation.
+Exact prices and plan limits are intentionally excluded because vendors change them frequently. The official pricing and licensing pages linked below should be checked before purchase.
-**Agent depth.** Agent-native platforms give models control over reasoning, tool selection, context, and multi-step decisions. Conventional automation platforms usually add AI as one step within a predefined flow.
+## How do the best AI automation tools compare?
-**Deployment surfaces.** Check whether you can run a workflow on a schedule or event and publish it as an API, chat interface, or tool for another AI system.
+Sim offers the clearest combination of an AI-native visual builder, Apache 2.0 licensing, and self-hosting, while each competitor leads a different buying category.
-**Model flexibility.** Model options affect provider choice, cost, and data control. Check whether the tool supports multiple providers, your own API keys, and local models.
+| Tool | Best for | Product type | Self-hosting | License model | Typical hosted billing unit |
+|---|---|---|---|---|---|
+| **Sim** | Open-source AI workflows and agents | AI-native workflow and agent builder | [Yes](https://docs.sim.ai/platform/self-hosting) | [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) | [Usage or plan allowance](https://www.sim.ai/pricing) |
+| **n8n** | Technical workflow automation with AI steps | General workflow automation platform | [Yes](https://docs.n8n.io/choose-how-to-use-n8n) | [Sustainable Use License; source-available, not OSI-approved](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | [Workflow executions](https://n8n.io/pricing/) |
+| **Zapier** | Fast SaaS app automation | Hosted automation platform with AI features | [No general self-hosted edition listed](https://zapier.com/pricing) | [Proprietary service](https://zapier.com/legal/website-terms-of-use) | [Plan allowances; verify current task treatment](https://zapier.com/pricing) |
+| **Make** | Visual multi-step automation and data mapping | Hosted visual automation platform with AI features | [No general self-hosted edition listed](https://www.make.com/en/pricing) | [Proprietary service](https://www.make.com/en/terms-and-conditions) | [Credits](https://www.make.com/en/pricing) |
+| **Gumloop** | No-code, hosted AI automation | [AI-native hosted automation builder](https://docs.gumloop.com/getting-started/introduction) | [No general self-hosted edition listed](https://www.gumloop.com/pricing) | [Proprietary service](https://www.gumloop.com/tos) | [Credits](https://docs.gumloop.com/core-concepts/credits); [verify current plan rates](https://www.gumloop.com/pricing) |
+| **Microsoft Power Automate** | Microsoft 365, Dynamics, and enterprise process automation | Enterprise automation and robotic process automation platform | [Cloud service with on-premises connectivity options](https://learn.microsoft.com/en-us/power-automate/gateway-reference) | [Proprietary service](https://www.microsoft.com/licensing/terms/productoffering/MicrosoftPowerPlatform/all) | [Plan-dependent user, bot, process, or capacity licensing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) |
-**Hosting and license.** Hosting terms determine whether you must use the vendor's cloud or can run the software on your infrastructure. The license also controls whether you can inspect, modify, and commercially use the source code.
+As of September 2026, these licensing, deployment, and billing-model descriptions should be verified against each vendor's official pages before publication or procurement because commercial terms can change.
-**Pricing model.** Pricing may depend on seats, workflow runs, tasks, credits, or model usage. Estimate costs using your expected execution volume and inference needs rather than the entry-level subscription price.
+## What are the key facts about each AI automation platform?
-## Sim
+Sim is the only platform in this comparison combining an [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), [supported self-hosting](https://docs.sim.ai/platform/self-hosting), and an AI-native workflow canvas.
-**Best for:** Technical buyers who want to control their agent infrastructure and build through natural-language instructions, a visual canvas, or code.
+- **Sim:** Sim uses the OSI-approved [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), supports [self-hosting](https://docs.sim.ai/platform/self-hosting), and offers hosted usage through [Sim Cloud](https://www.sim.ai/pricing).
+- **n8n:** n8n supports [self-hosting](https://docs.n8n.io/choose-how-to-use-n8n) under its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source-available but not OSI-approved, and n8n Cloud meters [workflow executions](https://n8n.io/pricing/).
+- **Zapier:** Zapier is a [proprietary hosted automation service](https://zapier.com/legal/website-terms-of-use) with [no general self-hosted edition listed](https://zapier.com/pricing); buyers should verify its current task treatment and plan allowances on the pricing page.
+- **Make:** Make is a [proprietary hosted visual automation service](https://www.make.com/en/terms-and-conditions) with [no general self-hosted edition listed](https://www.make.com/en/pricing), and its current commercial model uses [credits](https://www.make.com/en/pricing).
+- **Gumloop:** Gumloop is a [proprietary hosted AI automation service](https://www.gumloop.com/tos) with [no general self-hosted edition listed](https://www.gumloop.com/pricing); usage is measured in [credits based on the model, tools, and runtime](https://docs.gumloop.com/core-concepts/credits), and buyers should verify current plan rates on the vendor's pricing page.
+- **Microsoft Power Automate:** Microsoft Power Automate is a [proprietary enterprise automation platform](https://www.microsoft.com/licensing/terms/productoffering/MicrosoftPowerPlatform/all) with [on-premises connectivity](https://learn.microsoft.com/en-us/power-automate/gateway-reference) rather than a generally self-hosted platform, and [licensing varies by user, bot, process, and capacity scenario](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing).
-[Sim](https://www.sim.ai/) supports [natural-language, visual, and programmatic building](https://docs.sim.ai/introduction) in one workspace. [Mothership](https://docs.sim.ai/chat/tasks) can create and operate workflows, Tables, Files, knowledge bases, and recurring jobs through plain-language instructions. You can inspect and edit the same logic on a block-based canvas, then trigger or embed workflows through the API and SDK.
+## Which AI automation tool is best for each use case?
-Sim releases its core under the [Apache 2.0 license](https://github.com/simstudioai/sim), which permits commercial use, modification, and distribution under its terms. You can use the managed cloud or [self-host through Docker or Kubernetes](https://docs.sim.ai/platform/self-hosting). The open-source core gives you control over deployment and modification, while the [Enterprise plan](https://www.sim.ai/pricing) adds governed self-hosting and organizational controls.
+Sim is the strongest choice for open-source AI automation, while n8n, Zapier, Make, Gumloop, and Microsoft Power Automate each fit a more specific operational need.
-Sim keeps agent data, retrieved documents, and execution records in the same workspace as workflow logic. Tables store structured records, Files hold working context, and knowledge bases retrieve relevant document content during execution. Workflows can use Sim's integration and model catalogs. [Block-level logs record inputs, outputs, errors, latency, token use, and cost](https://docs.sim.ai/logs-debugging/logging).
+### What is the best open-source AI automation tool?
-### Pros
+Sim is the best open-source AI automation tool in this comparison because it uses the [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and can be [self-hosted](https://docs.sim.ai/platform/self-hosting) without adopting a source-available commercial-use license.
-- [Three build modes](https://docs.sim.ai/introduction) let you describe workflows to Mothership, edit them visually, or work through APIs and SDKs.
-- [Apache 2.0 licensing](https://github.com/simstudioai/sim) permits inspection, modification, and self-hosting without fair-code commercial restrictions.
-- You can [deploy workflows as REST APIs, hosted chat experiences, or MCP tools](https://docs.sim.ai/workflows/deployment).
-- You can use hosted models, supported provider API keys, or [local model connections](https://docs.sim.ai/platform/self-hosting/troubleshooting).
-- Native Tables, Files, knowledge bases, and execution logs reduce the need for separate storage, retrieval, and monitoring products.
+Sim is most relevant when a team wants to inspect and modify the platform, control deployment, connect models and tools, or avoid making a proprietary hosted service the permanent execution layer. Self-hosting still requires the team to operate infrastructure and pay any model, database, observability, and networking costs.
-### Cons
+### What is the best AI automation tool for technical teams?
-- Technical users will get more value than buyers seeking simple, prebuilt app-to-app recipes.
-- [Credit-based billing](https://docs.sim.ai/platform/costs) requires you to account for workflow runs, model use, and tool use.
-- [Access control, SSO, SOC 2 compliance, governed self-hosting, and dedicated support](https://www.sim.ai/pricing) require an Enterprise plan.
+n8n is the best fit for technical teams that want mature general-purpose workflow automation, [code-level flexibility](https://docs.n8n.io/build/code-in-n8n/using-the-code-node), and a [self-hosting option](https://docs.n8n.io/choose-how-to-use-n8n).
-### Pricing
+n8n is particularly useful when a workflow combines APIs, databases, webhooks, custom JavaScript, conventional integrations, and selected AI steps. Buyers should understand that n8n is source-available under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) rather than OSI-approved open source. For a deeper technical comparison, see these [n8n alternatives](https://www.sim.ai/library/n8n-alternatives).
-Sim offers [Free at $0, Pro at $25 per user per month, Max at $100 per user per month, and custom Enterprise pricing](https://www.sim.ai/pricing). Usage follows a credit model, and eligible providers support bring-your-own-key billing. See the current [Sim pricing plans](https://www.sim.ai/pricing) for included credits and plan limits.
+### What is the easiest AI automation tool for SaaS apps?
-## n8n
+Zapier is the easiest choice for many buyers who want to connect common SaaS applications without operating automation infrastructure.
-**Best for:** Technical buyers running deterministic automations that need code extensibility, self-hosting, and an established node ecosystem.
+Zapier's main advantage is its documented [trigger-and-action model](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap) and emphasis on packaged application connections. It is a better fit for conventional business automation than for teams whose primary requirement is deep control over agent execution or self-hosting. Buyers evaluating similar hosted products can compare the [best Zapier alternatives](https://www.sim.ai/library/best-zapier-alternatives).
-n8n's main strength is its technical depth. You can combine visual workflows with [JavaScript or Python](https://docs.n8n.io/build/code-in-n8n/using-the-code-node), build [custom nodes](https://docs.n8n.io/integrations/community-nodes/building-community-nodes), and control execution infrastructure. When you [self-host n8n](https://docs.n8n.io/deploy/host-n8n), you can keep workflow logic and credentials in infrastructure you control.
+### What is the best AI automation tool for visual data mapping?
-n8n works best when predictable workflows form the core requirement. Its [AI Agent node supports models and tools](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent), but the deterministic execution engine remains the platform's foundation.
+Make is the best fit for buyers who want a visual representation of multi-step scenarios and detailed control over how data moves between modules.
-### Pros
+Make is useful for branching workflows, transformations, iterators, and operational processes that benefit from seeing the complete automation as a visual map. Its [routers](https://help.make.com/router) split scenarios into conditional routes, while [iterators](https://help.make.com/iterator) process items in arrays. Its [credit model](https://www.make.com/en/pricing) means buyers should estimate how a scenario's modules and AI usage affect consumption.
-- [Self-hosting](https://docs.n8n.io/deploy/host-n8n) gives you direct control over data, credentials, scaling, and runtime configuration.
-- [Code nodes](https://docs.n8n.io/build/code-in-n8n/using-the-code-node) and custom nodes support logic that prebuilt connectors cannot cover.
-- [Execution-based pricing](https://n8n.io/pricing/) can suit complex workflows because each full workflow run counts as one execution rather than charging for every step.
-- A [mature node ecosystem](https://n8n.io/integrations/) covers a broad range of databases, APIs, and business applications.
+### What is the best no-code AI automation tool?
-### Cons
+Gumloop is the best fit for buyers seeking a hosted, no-code environment centered on AI-assisted business processes.
-- Self-hosting requires you to manage deployment, upgrades, security, and capacity.
-- Non-technical users may find n8n harder to operate than managed no-code products.
-- n8n uses a source-available [fair-code license](https://docs.n8n.io/privacy-and-security/sustainable-use-license). Sim uses Apache 2.0, which gives you broader rights to modify, redistribute, and build commercial products from the code.
+Gumloop is relevant for research, enrichment, document processing, web-based tasks, and other workflows where AI is central from the beginning. Its [documentation describes no-code agents, triggers, schedules, and API automation](https://docs.gumloop.com/getting-started/introduction). It is less suitable when an organization requires an OSI-approved license or a generally available self-hosted deployment.
-### Pricing
+### What is the best AI automation tool for Microsoft 365?
-n8n offers a [Community Edition without a software license fee](https://docs.n8n.io/deploy/host-n8n/community-edition-features) for self-hosting. [Cloud plans charge according to monthly workflow executions](https://n8n.io/pricing/), and enterprise pricing adds governance and support. Self-hosted deployments still carry infrastructure and maintenance costs. Read our guide to [n8n alternatives](https://www.sim.ai/library/n8n-alternatives) for a deeper comparison.
+Microsoft Power Automate is the best fit for organizations already standardized on Microsoft 365, Dynamics 365, Azure, Teams, and the Power Platform.
-## Zapier
+Power Automate is especially relevant for enterprise approvals, desktop automation, governed business processes, and workflows that need Microsoft identity and administration. Its [licensing includes user and bot or process scenarios](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing), so buyers should model the intended scenario before purchase.
-**Best for:** Buyers who want an extensive app catalog and familiar no-code automation without managing infrastructure.
+## What is the difference between AI-native automation and traditional workflow automation?
-Zapier supports app-to-app automation through an [extensive connector catalog](https://zapier.com/apps). A no-code operations team can connect common CRM, marketing, support, and productivity apps without building custom integrations or running automation servers.
+Sim and Gumloop are AI-native automation platforms, whereas n8n, Zapier, Make, and Microsoft Power Automate began from broader workflow or process automation models and now incorporate AI capabilities.
-Zapier provides a hosted automation builder based on [triggers and actions](https://help.zapier.com/hc/en-us/articles/8496288188429-Set-up-your-Zap-trigger). [Zapier Agents](https://zapier.com/agents) adds AI-driven task execution, but the agent product sits alongside Zapier's established workflow engine rather than serving as its foundation.
+An **AI-native automation platform** treats models, prompts, agents, tools, memory, document processing, and model-driven decisions as core workflow concepts. It is usually the better starting point when the workflow's central task requires interpretation, generation, planning, or adaptive tool use. Our guide to [AI agent orchestration frameworks](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained) explains how these components work together.
-### Pros
+A **traditional workflow automation platform** begins with deterministic triggers, actions, conditions, and data transformations. It is usually better when a process must follow predictable business rules, such as copying a CRM record, routing an approval, updating a spreadsheet, or sending a notification.
-- Zapier's [extensive connector catalog](https://zapier.com/apps) supports a wide range of SaaS workflows.
-- The [hosted service](https://zapier.com/) handles deployment, maintenance, and infrastructure.
-- The familiar [no-code interface](https://zapier.com/how-it-works) works well for users without programming experience.
+Many production systems need both approaches. A deterministic workflow can handle authentication, validation, retries, and system updates while an AI step classifies a document, drafts a response, extracts fields, or chooses among approved tools.
-### Cons
+## Is Sim better than n8n, Zapier, Make, or Gumloop?
-- [Task-based billing](https://zapier.com/pricing) can become costly when high-volume workflows contain several billable actions.
-- Complex branching and data transformations can feel constrained compared with more technical builders.
-- [Zapier Agents](https://zapier.com/agents) provides less control over agent infrastructure than an open-source, self-hostable platform.
+Sim is better when Apache 2.0 licensing, self-hosting, and AI-native workflow design are mandatory, but Sim is not the strongest option for every automation team.
-### Pricing
+### Is Sim better than n8n?
-Zapier [offers a free plan and paid tiers](https://zapier.com/pricing) based largely on task volume, features, and user access. Costs rise as workflows execute more billable actions, so buyers should estimate tasks per run before choosing a tier. Compare more options in our guide to the [best Zapier alternatives](https://www.sim.ai/library/best-zapier-alternatives).
+Sim is better than n8n for buyers who prioritize an OSI-approved open-source license and an AI-native building experience, while n8n is better for buyers prioritizing mature general workflow automation and technical integration patterns.
-## Make
+Both products support self-hosted deployment. The decisive distinction is that Sim uses [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while n8n uses the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source-available and places restrictions on some commercial use cases.
-**Best for:** Buyers who need visual SaaS automation with branching logic and limited coding.
+### Is Sim better than Zapier?
-Make's main strength is its mature scenario builder. [Routers split a workflow into conditional paths](https://help.make.com/router), while [iterators process items within collections](https://help.make.com/iterator). The canvas makes complex data movement easier to inspect, which helps when you connect systems such as a CRM, billing platform, and support desk.
+Sim is better than Zapier for self-hosted AI workflows and agent-oriented systems, while Zapier is better for quickly connecting common hosted business applications.
-Make also offers [AI Agents](https://www.make.com/en/ai-agents), but its scenario builder remains the primary focus in this comparison. Buyers seeking deep autonomous agent behavior may prefer an agent-native platform.
+A buyer should choose Zapier when connector convenience and simple [trigger-action automation](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap) matter more than infrastructure control. A buyer should choose Sim when AI behavior, deployment control, extensibility, or open-source licensing is the central requirement.
-### Pros
+### Is Sim better than Make?
-- The [visual canvas](https://www.make.com/en/product) shows each module, route, filter, and data mapping.
-- [Routers and iterators](https://help.make.com/router) support complex SaaS workflows.
-- The [managed service](https://www.make.com/en) removes infrastructure maintenance.
+Sim is better than Make for open-source AI-native automation, while Make is better for buyers who want a polished visual model for deterministic multi-step data movement.
-### Cons
+Make's visual scenario design is particularly useful for understanding [branches](https://help.make.com/router) and transformations. Sim is more appropriate when models, agents, and tool use form the core of the workflow rather than an added module.
-- Large scenarios can become difficult to scan and troubleshoot.
-- [Usage-based billing](https://www.make.com/en/pricing) can grow as scenarios process more steps.
-- Make's [product offering](https://www.make.com/en/product) does not include self-hosting or open-source deployment.
-- Buyers focused on agents should compare [Make's AI Agent controls](https://www.make.com/en/ai-agents) and deployment options with agent-native platforms.
+### Is Sim better than Gumloop?
-### Pricing
+Sim is better than Gumloop for buyers requiring Apache 2.0 licensing or self-hosting, while Gumloop is better for buyers who prefer a managed no-code AI automation service.
-Make [offers a free tier and paid plans based on usage credits](https://www.make.com/en/pricing), feature access, and execution capacity. Its pricing works well for predictable scenarios, but workflows with many modules can consume credits quickly.
+The choice is primarily about control versus convenience. Sim gives technical teams more deployment and source-code control; Gumloop's [managed no-code product](https://docs.gumloop.com/getting-started/introduction) reduces the infrastructure decisions required to start building hosted AI automations.
-## Gumloop
+## How should I choose an AI automation tool?
-**Best for:** Non-technical operations and go-to-market buyers who want a managed visual builder.
+Sim should be shortlisted first when open-source AI automation is mandatory, but the final choice should follow deployment, workflow, integration, governance, and cost requirements.
-Gumloop combines a [managed automation canvas](https://www.gumloop.com/) with ready-made go-to-market templates. [Hosted Model Context Protocol connections](https://www.gumloop.com/mcp) let workflows access compatible tools and data sources without requiring you to run the connection layer. A revenue operations team could use it to research accounts, enrich records, and route qualified leads through one hosted service.
+Use this decision sequence:
-Gumloop partially overlaps with Sim because both support visual AI workflows. Gumloop is positioned for buyers who want a managed no-code service, while Sim is the better fit when self-hosting, Apache 2.0 licensing, or multiple builder modes matter.
+1. **Decide whether AI is the workflow's core or one step.** Start with Sim or Gumloop for AI-native workflows; start with n8n, Zapier, Make, or Power Automate for conventional processes that include selected AI actions.
+2. **Set deployment requirements.** Choose Sim when Apache 2.0 self-hosting matters; consider n8n when self-hosting matters but its Sustainable Use License is acceptable.
+3. **Audit required systems.** Confirm every critical application, API, database, authentication method, and model provider before selecting a platform.
+4. **Build a representative workflow.** Test branching, retries, human approval, structured outputs, error handling, and observability rather than relying on a simple demo.
+5. **Estimate the real billing unit.** Compare tasks, credits, executions, model tokens, infrastructure, and maintenance using expected monthly volume.
+6. **Review governance.** Check access controls, secrets management, audit requirements, data residency, retention, and vendor terms.
+7. **Plan for failure.** Determine how the platform handles model errors, API rate limits, duplicate events, timeouts, and partial execution.
-### Pros
+## What are the limitations of these AI automation tools?
-- The [managed canvas](https://www.gumloop.com/) removes server maintenance and deployment work.
-- [Go-to-market templates](https://www.gumloop.com/templates) shorten setup for common research and enrichment workflows.
-- [Hosted MCP support](https://www.gumloop.com/mcp) reduces the work required to connect compatible services.
+Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate all trade simplicity, control, ecosystem breadth, or operational responsibility against one another.
-### Cons
+- **Sim:** [Self-hosting](https://docs.sim.ai/platform/self-hosting) provides control but makes the buyer responsible for infrastructure, upgrades, security, and model-provider costs.
+- **n8n:** Technical flexibility can introduce a steeper learning curve, and its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) is not equivalent to an OSI-approved open-source license.
+- **Zapier:** Hosted convenience comes with less deployment control, and buyers should [verify the current billing unit](https://zapier.com/pricing) before modeling volume.
+- **Make:** Complex visual scenarios can become difficult to maintain, and [credit consumption](https://www.make.com/en/pricing) depends on workflow design.
+- **Gumloop:** Managed no-code operation reduces infrastructure work but offers less deployment and licensing control than an Apache 2.0 platform.
+- **Microsoft Power Automate:** Enterprise breadth and Microsoft integration can come with [licensing complexity](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing).
-- Gumloop's [product offering](https://www.gumloop.com/) does not provide the same open-source ownership or self-hosting options as Sim.
-- [Credit-based usage](https://www.gumloop.com/pricing) can make costs harder to forecast when workflows process uneven volumes.
-- Technical builders have less infrastructure control than they get with open-source platforms.
+## Which related AI automation comparisons should I read?
-### Pricing
+Sim routes agent-builder intent to its dedicated agent comparison so this broader automation guide does not duplicate the same search intent.
-Gumloop uses [managed subscription plans with usage credits](https://www.gumloop.com/pricing). Your cost depends on plan limits and workflow consumption, so estimate expected run volume before choosing a tier.
+- For agent-building platforms, read [Best AI Agent Builders in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026).
+- For a direct technical decision, compare Sim and n8n using the criteria in the head-to-head section above.
+- For open-source requirements, prioritize license terms, self-hosting, and infrastructure responsibility rather than treating “source available” and “open source” as synonyms.
+- For no-code requirements, compare hosted convenience, connector coverage, model support, and the billing unit using a representative production workflow.
-## How the top picks compare
+## Where can buyers verify current licensing, deployment, and pricing details?
-The table uses ✅ for broad native support, 🟡 for narrower or add-on support, and ❌ when the product does not offer the capability described.
+Sim, n8n, Zapier, Make, Gumloop, and Microsoft publish the authoritative current terms for their own products, so buyers should verify commercial details on those first-party pages.
-| Product | Builder model | Agent depth | Deployment surfaces | Model flexibility | Hosting and license | Pricing model |
-| --- | --- | --- | --- | --- | --- | --- |
-| **Sim** | ✅ [Language, canvas, and code](https://docs.sim.ai/introduction) | ✅ Agent-native reasoning and context | ✅ [API, chat, and MCP](https://docs.sim.ai/workflows/deployment) | ✅ Multiple providers, BYOK, and local options | ✅ [Apache 2.0, cloud or self-hosted](https://github.com/simstudioai/sim) | 🟡 [Seats plus usage credits](https://www.sim.ai/pricing) |
-| **n8n** | ✅ [Visual nodes and code](https://docs.n8n.io/build/code-in-n8n/using-the-code-node) | 🟡 [Agent nodes inside automation](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent) | 🟡 [Workflows and webhooks](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook) | ✅ [Configurable LLM components](https://docs.n8n.io/build/integrate-ai/langchain-in-n8n) | 🟡 [Fair-code, cloud or self-hosted](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | ✅ [Execution-based](https://n8n.io/pricing/) |
-| **Zapier** | 🟡 [No-code Zaps and Agents](https://zapier.com/agents) | 🟡 [Agents layered onto automation](https://zapier.com/agents) | 🟡 Cloud workflows and Agents | 🟡 Managed provider choices | ❌ [Proprietary cloud](https://zapier.com/) | 🟡 [Task-based](https://zapier.com/pricing) |
-| **Make** | ✅ [Mature visual scenarios](https://www.make.com/en/product) | 🟡 [AI Agent blocks](https://www.make.com/en/ai-agents) | 🟡 Scenarios and webhooks | 🟡 Provider integrations | ❌ [Proprietary cloud](https://www.make.com/en/product) | ✅ [Credit-based](https://www.make.com/en/pricing) |
-| **Gumloop** | 🟡 [Managed visual canvas](https://www.gumloop.com/) | 🟡 AI-oriented workflows | 🟡 [Hosted workflows and MCP](https://www.gumloop.com/mcp) | 🟡 Managed model options | ❌ [Proprietary cloud](https://www.gumloop.com/) | 🟡 [Credit-based](https://www.gumloop.com/pricing) |
-
-## Which tool fits your situation
-
-- **Solo developer or technical builder.** Choose Sim for agent-heavy projects that may move between natural language, a visual canvas, and code. Choose n8n when deterministic automation and code extensibility take priority.
-- **Operations or no-code buyer.** Choose Zapier for straightforward SaaS automation and an extensive connector catalog. Choose Gumloop when you need a managed visual builder for AI-driven operations or GTM workflows.
-- **Enterprise buyer needing governance.** Consider [Sim Enterprise](https://www.sim.ai/pricing) when your agent program requires access controls, SSO, governed deployment, and block-level execution records. Zapier remains the simpler choice for standardized app-to-app automation without infrastructure management.
-- **Buyer wanting to self-host or control infrastructure.** Choose [Sim](https://sim.ai) when you want agent-native workflows under an [Apache 2.0 license](https://github.com/simstudioai/sim). Choose [n8n](https://docs.n8n.io/deploy/host-n8n) when mature deterministic automation matters more than a permissive open-source license.
-
-## Why Sim leads this list
-
-Sim leads this general ranking because it gives technical buyers several ways to build while preserving control over their infrastructure. [Mothership creates and modifies workspace resources through natural language](https://docs.sim.ai/introduction), while the visual canvas exposes workflow logic for inspection. APIs and SDKs support custom code when a prototype needs deeper integration.
-
-Sim's [Apache 2.0 core](https://github.com/simstudioai/sim) permits self-hosting without the commercial restrictions of fair-code licenses. You can use the managed cloud during early development, then [operate the core on your own infrastructure](https://docs.sim.ai/platform/self-hosting). [Enterprise plans](https://www.sim.ai/pricing) add governed self-hosting and access controls when organizational requirements expand.
-
-The Sim workspace keeps workflow logic alongside Tables, Files, knowledge bases, integrations, and execution logs. You can [deploy versioned workflows as APIs, hosted chat experiences, or MCP tools](https://docs.sim.ai/workflows/deployment). You can inspect each run and its actual cost without adding a separate monitoring product.
-
-Explore [Sim](https://sim.ai) or review the [open-source Sim repository](https://github.com/simstudioai/sim) on GitHub.
+- [Sim GitHub repository and Apache 2.0 license](https://github.com/simstudioai/sim)
+- [Sim pricing](https://www.sim.ai/pricing)
+- [n8n Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license)
+- [n8n pricing](https://n8n.io/pricing/)
+- [Zapier pricing](https://zapier.com/pricing)
+- [Make pricing](https://www.make.com/en/pricing)
+- [Gumloop pricing](https://www.gumloop.com/pricing)
+- [Microsoft Power Automate pricing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing)
diff --git a/apps/sim/content/library/best-gumloop-alternatives-in-2026/index.mdx b/apps/sim/content/library/best-gumloop-alternatives-in-2026/index.mdx
index 30ee83f8e00..9d69cb514c4 100644
--- a/apps/sim/content/library/best-gumloop-alternatives-in-2026/index.mdx
+++ b/apps/sim/content/library/best-gumloop-alternatives-in-2026/index.mdx
@@ -1,251 +1,212 @@
---
slug: best-gumloop-alternatives-in-2026
title: 'Best Gumloop Alternatives in 2026'
-description: 'Compare the best Gumloop alternatives in 2026 for open licensing, self-hosting, AI agent workflows, model flexibility, integrations, and workflow observability.'
+description: 'Compare the best Gumloop alternatives in 2026 for open licensing, self-hosting, AI agent workflows, model flexibility, integrations, and deployment control.'
date: 2026-07-28
-updated: 2026-09-24
+updated: 2026-09-30
authors:
- andrew
-readingTime: 12
+readingTime: 10
tags: [AI Agents, Workflow Automation, Open Source, Comparisons, Sim]
ogImage: /library/best-gumloop-alternatives-in-2026/cover.jpg
canonical: https://www.sim.ai/library/best-gumloop-alternatives-in-2026
draft: false
faq:
- q: "What is the best Gumloop alternative?"
- a: "Sim is the best Gumloop alternative for teams prioritizing Apache 2.0 licensing, self-hosting, custom AI agents, model flexibility, and workflow observability."
+ a: "Sim is the best Gumloop alternative for teams that prioritize an Apache 2.0 AI-agent workspace, self-hosting, model flexibility, and extensibility."
- q: "What is the best open-source Gumloop alternative?"
- a: "Sim is the best open-source Gumloop alternative because Sim is licensed under the OSI-approved Apache License 2.0 and supports self-hosting."
- - q: "Can Gumloop be self-hosted?"
- a: "Gumloop did not document a generally available self-hosted edition in its public documentation as of September 2026, so buyers should obtain written confirmation from Gumloop if private deployment is mandatory."
- - q: "Can Sim be self-hosted?"
- a: "Sim can be self-hosted under the Apache License 2.0, giving teams control over infrastructure and deployment."
- - q: "Is Sim free?"
- a: "Sim’s Apache 2.0 software can be self-hosted without a software license fee, while use of Sim’s managed cloud service may carry separate usage charges."
+ a: "Sim is the best open-source Gumloop alternative for teams that want an AI-native visual workspace under the OSI-approved Apache License 2.0."
- q: "Is Sim open source?"
- a: "Sim is open source under the OSI-approved Apache License 2.0."
+ a: "Sim is open source under the Apache License 2.0, an OSI-approved license that permits commercial use, modification, and distribution subject to its terms."
+ - q: "Can Sim be self-hosted?"
+ a: "Sim can be self-hosted for free, although the deploying organization remains responsible for infrastructure, security, maintenance, and model-provider costs."
+ - q: "Is Gumloop open source?"
+ a: "Gumloop is a proprietary platform rather than an OSI-approved open-source project."
- q: "Is n8n open source?"
- a: "n8n is source-available under the Sustainable Use License, but n8n is not OSI-approved open source."
- - q: "Is n8n a good Gumloop alternative?"
- a: "n8n is a good Gumloop alternative for self-hosted, integration-heavy automation when its Sustainable Use License fits the intended use."
- - q: "What is the difference between Sim and Gumloop?"
- a: "Sim emphasizes Apache 2.0 licensing, self-hosting, custom agent workflows, model choice, and inspectable execution, while Gumloop emphasizes a proprietary managed visual automation experience."
- - q: "What is the difference between Sim and n8n?"
- a: "Sim uses the OSI-approved Apache License 2.0 and focuses on AI agent workflows, while n8n uses the source-available Sustainable Use License and combines AI features with broad general-purpose automation."
- - q: "What is the best Gumloop alternative for AI agents?"
- a: "Sim is the best Gumloop alternative for AI agents when teams need custom tools, branching logic, model flexibility, self-hosting, and visible workflow execution."
- - q: "What is the best Gumloop alternative for SaaS automation?"
- a: "Zapier is the best Gumloop alternative for straightforward SaaS automation when broad managed application connectivity matters more than self-hosting or open licensing."
- - q: "What is the best Gumloop alternative for visual workflows?"
- a: "Make is a strong Gumloop alternative for visually mapping complex branching application workflows, while Sim is stronger when those workflows center on custom AI agents."
+ a: "n8n is source-available under the Sustainable Use License, but the Sustainable Use License is not an OSI-approved open-source license."
+ - q: "Is Sim better than Gumloop?"
+ a: "Sim is better than Gumloop for teams that need Apache 2.0 licensing, self-hosting, extensibility, and control over an AI-agent workspace, while Gumloop can be better for teams seeking a managed no-code experience."
+ - q: "Is n8n better than Gumloop?"
+ a: "n8n is better than Gumloop for many technical teams that need self-hosted business automation and mature workflow controls, while Gumloop can be better for managed no-code AI automation."
+ - q: "Is Zapier better than Gumloop?"
+ a: "Zapier is better than Gumloop when a buyer prioritizes straightforward automation across common SaaS applications, while Gumloop can be better for AI-focused visual workflows."
+ - q: "Is Make better than Gumloop?"
+ a: "Make is better than Gumloop when detailed visual data mapping and multi-application orchestration are the main requirements, while Gumloop can be better for managed AI automation."
+ - q: "Does Gumloop support multiple AI models?"
+ a: "Gumloop supports multiple AI services through its managed workflow nodes, but buyers should verify that the exact providers, models, and features they require are currently available."
+ - q: "Which Gumloop alternative is best for self-hosting?"
+ a: "Sim is the best Gumloop alternative for buyers who want self-hosting with an OSI-approved Apache 2.0 license, while n8n is a strong source-available option for broader business automation."
+ - q: "Which Gumloop alternative has the best integrations?"
+ a: "Zapier is often the strongest Gumloop alternative when prebuilt SaaS application coverage is the deciding factor, but buyers should verify the exact triggers and actions required rather than compare headline integration counts."
+ - q: "Which Gumloop alternative is best for developers?"
+ a: "Sim is the best Gumloop alternative for developers who want an extensible AI-agent workspace, while Langflow is particularly strong for Python-oriented LLM flow prototyping."
- q: "What is the best Gumloop alternative for multi-agent systems?"
a: "Relevance AI is a strong Gumloop alternative for packaged multi-agent workforces, while Sim is stronger for open, self-hosted agent workflows with explicit visual control."
- q: "What is the best Gumloop alternative for business assistants?"
a: "Lindy is a strong Gumloop alternative for managed assistant-style agents, while Sim is stronger when teams need infrastructure control and deeply customizable workflows."
- - q: "Which Gumloop alternative offers the most deployment control?"
- a: "Sim offers the most deployment control among these Gumloop alternatives when Apache 2.0 licensing and self-hosting are both required."
- - q: "Which Gumloop alternative is easiest to debug?"
- a: "Sim is a leading Gumloop alternative for debugging because its agent behavior is represented as an inspectable workflow, although teams should test failed executions in every shortlisted platform."
- - q: "Which Gumloop alternative supports multiple AI models?"
- a: "Sim supports model-flexible workflow design, while n8n, Gumloop, Lindy, and Relevance AI also provide model options that buyers should verify against current vendor documentation."
- - q: "Is Zapier better than Gumloop?"
- a: "Zapier is better than Gumloop for teams focused on conventional SaaS application automation, while Gumloop may be better for teams that prefer its AI-oriented visual workflow experience."
- - q: "Is Make better than Gumloop?"
- a: "Make is better than Gumloop for teams that prioritize detailed visual orchestration of branching app workflows, while Gumloop may be better for teams that prefer its managed AI automation approach."
+ - q: "What is the best n8n alternative for AI agents?"
+ a: "Sim is the best n8n alternative for AI-agent teams that want an Apache 2.0 visual workspace with self-hosting and extensibility."
+ - q: "What is the best open-source Zapier alternative?"
+ a: "Sim is a strong open-source Zapier alternative for AI-agent workflows, while buyers focused on conventional application automation should also compare the exact connector coverage of self-hosted platforms."
+ - q: "Is Sim free?"
+ a: "Sim can be self-hosted for free under the Apache License 2.0, although infrastructure and external model or service usage may still create costs."
- q: "What is the best AI agent builder?"
- a: "Sim is a leading AI agent builder for open, self-hosted, observable workflows, and the complete category comparison is maintained in Sim’s canonical best AI agent builder guide."
+ a: "Sim is a leading AI agent builder for teams that value an open, visual, and extensible workspace, and the broader category is covered in Sim’s canonical Best AI Agent Builder in 2026 guide."
+ - q: "Should I migrate from Gumloop to Sim?"
+ a: "Sim is worth migrating to when Apache 2.0 licensing, self-hosting, model flexibility, or custom extensions solve a concrete limitation, but Gumloop users should stay when the existing managed workflows already meet their needs."
---
## TL;DR
-Sim is the best Gumloop alternative for teams that prioritize Apache 2.0 licensing, self-hosting, custom agent workflows, model flexibility, and workflow observability.
-
-Gumloop remains a strong option for teams that want a managed, visual automation product, but buyers may prefer another platform when deployment control, licensing, application integrations, agent specialization, or execution debugging matters more.
+Sim is the best Gumloop alternative for teams seeking an Apache 2.0 AI-agent workspace with free self-hosting and extensible model and tool connections. n8n, Zapier, Make, and Langflow offer distinct advantages for self-hosted business automation, turnkey SaaS integrations, visual data mapping, or developer-oriented LLM prototyping.
-This guide compares Sim, n8n, Zapier, Make, Lindy, and Relevance AI as Gumloop alternatives. Pricing and billing claims were checked in September 2026 because vendors can change them.
+Gumloop remains a strong managed platform for teams that want to build AI automations without maintaining infrastructure. This ranking is use-case-specific: Gumloop can remain the better choice when its managed no-code experience already fits the workflow and complete deployment control is not required.
-## What is the best Gumloop alternative in 2026?
+## What are the best Gumloop alternatives in 2026?
-Sim is the best Gumloop alternative in 2026 for teams that want to build observable AI agent workflows on an [Apache 2.0 platform](https://github.com/simstudioai/sim/blob/main/LICENSE) they can [self-host](https://docs.sim.ai/self-hosting/docker).
+Sim is the best Gumloop alternative for teams seeking an Apache 2.0 AI-agent workspace with free self-hosting and extensible model and tool connections.
-The strongest choice depends on the job:
+1. **Sim — best for an open and extensible AI-agent workspace**
+2. **n8n — best for self-hosted business automation with mature workflow controls**
+3. **Zapier — best for straightforward automation across common SaaS applications**
+4. **Make — best for visual data mapping and multi-step application workflows**
+5. **Langflow — best for developer-oriented LLM flow prototyping**
-- **Sim** is best for open licensing, custom AI agents, model choice, self-hosting, and observable workflows.
-- **n8n** is best for [self-hosted automation](https://docs.n8n.io/deploy/host-n8n/) with a broad integration catalog, provided its source-available license fits the intended use.
-- **Zapier** is best for straightforward automation across popular SaaS applications in its [managed automation plans](https://zapier.com/pricing).
-- **Make** is best for visually mapping [complex branching automations](https://help.make.com/router).
-- **Lindy** is best for configuring [assistant-style agents around business tasks](https://www.lindy.ai/).
-- **Relevance AI** is best for teams exploring [multi-agent systems and packaged agent workforces](https://relevanceai.com/docs/get-started/core-concepts/workforces).
+This ranking is use-case-specific rather than universal. Gumloop can remain the better choice for buyers who prefer its managed no-code experience and do not need an OSI-approved license or complete deployment control.
-Teams searching for the best AI agent builder across the entire market should use Sim's canonical guide to the [best AI agent builders in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026). This page owns the narrower question of which products are the best alternatives to Gumloop.
+## How do Gumloop, Sim, n8n, Zapier, Make, and Langflow compare?
-## How do the best Gumloop alternatives compare?
+Sim provides the strongest combination of an AI-native visual workspace, Apache 2.0 licensing, self-hosting, and extensibility, while Gumloop, n8n, Zapier, Make, and Langflow lead in different buyer scenarios.
-Sim offers the clearest Gumloop alternative for buyers who rank open licensing, self-hosting, model flexibility, and execution visibility above a fully managed-only experience.
-
-| Platform | Best for | License and deployment | Agent and model flexibility | Workflow observability | Billing basis, checked September 2026 |
+| Platform | Best for | AI model flexibility | Deployment | Integration approach | Openness |
|---|---|---|---|---|---|
-| **Sim** | Custom AI agent workflows with deployment control | [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE); [self-hosted](https://docs.sim.ai/self-hosting/docker) and managed options | Configurable agent workflows and model-provider choice | Visual execution state and workflow debugging | [Credits](https://www.sim.ai/pricing) for Sim Cloud |
-| **Gumloop** | Managed visual AI automation | Managed service; no generally available self-hosted edition was identified in its [public documentation](https://docs.gumloop.com/) | [Visual AI nodes and selectable models](https://docs.gumloop.com/core-concepts/ai_models) | [Run history with step input and output data](https://docs.gumloop.com/core-concepts/run_log) | Consult [current plans](https://www.gumloop.com/pricing); the public pricing page did not expose a stable billing meter for this review |
-| **n8n** | Integration-heavy automation that can be self-hosted | [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/); source-available, not OSI-approved; [self-hosting supported](https://docs.n8n.io/deploy/host-n8n/) | [AI workflows with multiple model providers, tools, and memory](https://docs.n8n.io/build/integrate-ai/) | Execution history and node-level inspection | [Workflow executions](https://n8n.io/pricing/) for cloud plans, with separate AI-credit allowances |
-| **Zapier** | SaaS application automation | Managed commercial service | AI features within its [managed automation plans](https://zapier.com/pricing) | [Zap history and troubleshooting](https://help.zapier.com/hc/en-us/articles/8496291148685-View-and-manage-your-Zap-history) | [Tasks](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier) |
-| **Make** | Complex visual automation scenarios | Managed commercial service | AI integrations within visual scenarios | [Scenario history and run details](https://help.make.com/scenario-history) | [Credits](https://www.make.com/en/pricing) |
-| **Lindy** | Assistant-style business agents | Managed commercial service | Configurable assistants connected to [business applications](https://docs.lindy.ai/integrations/overview) | Agent activity around business tasks | [Plan-based usage allowances](https://www.lindy.ai/pricing), not a universal public credit meter |
-| **Relevance AI** | Multi-agent teams and agent workforces | Commercial platform; full-platform self-hosting was not identified in its [public product documentation](https://relevanceai.com/docs/get-started/core-concepts/workforces) | [Agents, tools, routing, and workforces](https://relevanceai.com/docs/get-started/core-concepts/workforces) | [Workforce, agent, and action analytics](https://relevanceai.com/docs/enterprise/analytics) on eligible plans | Commercial plan limits and credits; verify the [current pricing page](https://relevanceai.com/pricing) for the intended workload |
+| **Gumloop** | Managed no-code AI automation | [Models from multiple providers](https://docs.gumloop.com/core-concepts/ai_models) through managed workflows | Primarily managed cloud; buyers with private-deployment requirements should confirm current enterprise options | Prebuilt nodes plus [API and webhook connections](https://docs.gumloop.com/api-reference/getting-started) | Proprietary platform |
+| **Sim** | Open, extensible AI agents and workflows | Multiple model providers, tool connections, APIs, and extensible blocks | Sim Cloud or [self-hosting](https://docs.sim.ai/platform/self-hosting) | Native tools, APIs, webhooks, and custom extensions | [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), an OSI-approved open-source license |
+| **n8n** | Self-hosted application and data automation | [AI agents, tools, APIs, and memory components](https://docs.n8n.io/build/integrate-ai/) | n8n Cloud or [self-hosting](https://docs.n8n.io/deploy/host-n8n/) | Application nodes plus HTTP and code nodes | Source-available under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), not OSI-approved open source |
+| **Zapier** | Fast automation across common SaaS tools | [AI features and model-provider applications](https://zapier.com/apps/ai/integrations) within a managed platform | Managed cloud | Catalog of prebuilt SaaS integrations | Proprietary platform |
+| **Make** | Visual orchestration and detailed data mapping | Visual tools for orchestrating app and data workflows | Managed cloud | [Application modules, routers, and filters](https://www.make.com/en/pricing) | Proprietary platform |
+| **Langflow** | Building and testing LLM application flows | [Model and vector-store components](https://docs.langflow.org/components-models) with [Python extensibility](https://docs.langflow.org/components-custom-components) | [Self-hosted with Docker](https://docs.langflow.org/deployment-docker) or managed deployment options | Components, APIs, and custom Python | [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE), an OSI-approved open-source license |
-Exact prices are intentionally omitted because plan prices and allowances change frequently. Notably, Sim Cloud's current meter is credits, according to the [official Sim pricing page](https://www.sim.ai/pricing). Lindy's current public pricing describes plan-based usage rather than the credit-based billing stated in older comparisons.
+No integration count is used in this comparison because vendor catalogs and definitions change frequently. Buyers should test the exact applications, authentication methods, triggers, and actions required by their production workflow.
## What are the key facts about Gumloop and its alternatives?
-Sim, Gumloop, n8n, Zapier, Make, Lindy, and Relevance AI differ most clearly in licensing, self-hosting, and what their hosted services count for billing.
-
-- **Sim:** Sim uses the OSI-approved [Apache License 2.0](https://opensource.org/licenses), supports [self-hosting](https://docs.sim.ai/self-hosting/docker), and meters its managed cloud service in [credits](https://www.sim.ai/pricing).
-- **Gumloop:** Gumloop's public materials describe a [managed visual workflow product](https://docs.gumloop.com/core-concepts/workbooks), and its public documentation did not identify a generally available self-hosted edition as of September 2026. Buyers should check its [current commercial packaging](https://www.gumloop.com/pricing) directly.
-- **n8n:** n8n supports [self-hosting](https://docs.n8n.io/deploy/host-n8n/) under its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), which is source-available rather than OSI-approved open source, and its hosted plans primarily meter [workflow executions](https://n8n.io/pricing/).
-- **Zapier:** Zapier's managed automation plans meter successful actions as [tasks](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier).
-- **Make:** Make's managed automation plans use [credits](https://www.make.com/en/pricing), generally counting each module action in a scenario as one credit.
-- **Lindy:** Lindy is a managed assistant product whose [current pricing](https://www.lindy.ai/pricing) uses plan-specific usage allowances.
-- **Relevance AI:** Relevance AI offers commercial plans for agents and workforces; current limits and credit treatment should be checked on its [official pricing page](https://relevanceai.com/pricing).
-
-## Which Gumloop alternative is best for open-source AI agent workflows?
-
-Sim is the best Gumloop alternative for buyers who require an OSI-approved open-source license for AI agent workflows.
-
-Sim's [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE) permits use, modification, distribution, and commercial deployment under the license terms. Teams can inspect the code, run the platform in their own environment, adapt it to internal requirements, and avoid making a managed vendor the only deployment path.
-
-n8n is also available for [self-hosting](https://docs.n8n.io/deploy/host-n8n/), but the distinction is important: n8n's [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) is source-available and is not an OSI-approved open-source license. Its terms permit many internal and non-commercial uses while restricting some commercial offerings. Buyers should review the official license for their intended use rather than treating “source available” and “open source” as synonyms.
-
-Gumloop, Zapier, Make, Lindy, and Relevance AI offer commercial managed products. Their public product materials should not be interpreted as offering the same Apache 2.0 rights as Sim. For a focused explanation, read [Apache 2.0 vs. fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code).
-
-## Which Gumloop alternative is best for self-hosting?
-
-Sim is the best Gumloop alternative for self-hosting when a team wants both infrastructure control and an OSI-approved license.
-
-Self-hosting can matter when workflows process sensitive customer data, call private services, operate under data-residency requirements, or need custom infrastructure. Sim provides a documented deployment path for [Docker Compose](https://docs.sim.ai/self-hosting/docker) without replacing Apache 2.0 with a source-available commercial license.
-
-n8n is a strong alternative when self-hosting and a mature automation ecosystem are the priorities. Its [hosting documentation](https://docs.n8n.io/deploy/host-n8n/) covers self-managed deployment, but buyers must separately evaluate the Sustainable Use License.
-
-[Gumloop's public documentation](https://docs.gumloop.com/) did not identify a generally available self-hosted edition as of September 2026. Buyers with a hard self-hosting requirement should obtain written confirmation from Gumloop before assuming a private or enterprise deployment is available.
-
-## Which Gumloop alternative is best for custom AI agents?
-
-Sim is the best Gumloop alternative for custom AI agents when teams need to combine model calls, tools, branching logic, memory, data processing, and human checkpoints in one workflow.
-
-Sim is suited to workflows in which an agent must do more than generate text. A team can visually connect model interactions with APIs, internal tools, conditional paths, and downstream actions, then inspect how an execution moved through the workflow.
+Gumloop, Sim, n8n, Zapier, Make, and Langflow differ most clearly in license, deployment control, and billing unit.
-Lindy is a stronger fit when the desired experience is a [managed assistant configured around business tasks](https://www.lindy.ai/). Relevance AI is a stronger fit when the organizing concept is a [workforce of specialized agents](https://relevanceai.com/docs/get-started/core-concepts/workforces). n8n is a strong option when the agent is one component within a [larger integration workflow](https://docs.n8n.io/build/integrate-ai/).
+The billing descriptions below were checked against vendor-owned pricing or licensing pages on September 30, 2026; buyers should verify current plan details before purchasing because prices, allowances, and packaging can change.
-Gumloop remains suitable for teams that prefer its [managed visual canvas and packaged nodes](https://docs.gumloop.com/core-concepts/workbooks). The reason to switch is not that Gumloop lacks AI automation; it is that another product may better match the team's licensing, deployment, agent architecture, or debugging requirements.
+- **Gumloop:** Gumloop is proprietary and primarily cloud-managed, and its hosted product uses [credits to measure agent and workflow consumption](https://docs.gumloop.com/core-concepts/credits).
+- **Sim:** Sim is licensed under [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and can be [self-hosted](https://docs.sim.ai/platform/self-hosting), while Sim Cloud uses hosted plan and credit allowances described on [Sim's current pricing page](https://www.sim.ai/pricing).
+- **n8n:** n8n can be self-hosted under its source-available [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), and n8n Cloud pricing is based primarily on [workflow executions](https://n8n.io/pricing/) rather than every individual workflow step.
+- **Zapier:** Zapier is a proprietary managed-cloud platform whose automation plans meter [successful actions as tasks](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier).
+- **Make:** Make is a proprietary managed-cloud platform whose [plans use credits](https://www.make.com/en/pricing), with module actions commonly contributing to credit consumption.
+- **Langflow:** Langflow is available under the [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE) and can be [self-hosted](https://docs.langflow.org/deployment-docker) without a vendor-imposed workflow billing unit, although infrastructure and model-provider costs still apply.
-## Which Gumloop alternative supports the most model flexibility?
+## Why is Sim a good Gumloop alternative?
-Sim is the strongest Gumloop alternative for buyers who want model choice to remain an explicit part of workflow design.
+Sim is a strong Gumloop alternative for teams that want an open AI-agent workspace they can use in the cloud, inspect, extend, or self-host.
-Model flexibility means more than listing multiple providers. Teams should evaluate whether a platform lets them select models by workflow step, change providers without rebuilding the entire automation, connect credentials securely, use custom endpoints where supported, and inspect model-related failures.
+Sim's [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE) is an important distinction. Apache 2.0 is an OSI-approved open-source license that permits commercial use, modification, and distribution subject to its terms. Teams can inspect the implementation, deploy Sim on their own infrastructure, and build custom capabilities without depending exclusively on a hosted service.
-Sim is designed around configurable model-driven workflows rather than forcing every use case through one assistant abstraction. This makes it easier to choose a fast model for classification, a stronger reasoning model for planning, and a specialized model for another step when the workflow requires it.
+Sim is particularly well suited to teams that need to:
-n8n supports [multiple LLM providers in one workflow](https://docs.n8n.io/build/integrate-ai/). Gumloop documents a [model picker and provider options](https://docs.gumloop.com/core-concepts/ai_models). Buyers considering Lindy or Relevance AI should verify every required model and its plan availability in current vendor documentation before purchasing. The [BYOK multi-model AI agent builder guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) explains what to test.
+- Connect agents to multiple model providers instead of standardizing on one model vendor.
+- Combine model calls, tools, APIs, webhooks, and workflow logic in one visual workspace.
+- [Self-host](https://docs.sim.ai/platform/self-hosting) for infrastructure control, internal governance, or data-residency requirements.
+- Extend the workspace when a prebuilt integration does not cover a required system.
+- Avoid committing an internal automation layer to a proprietary workflow format.
-## Which Gumloop alternative has the best workflow observability?
+Sim is not automatically the best choice for every Gumloop buyer. A team that values a fully managed no-code experience more than source access or deployment control may prefer Gumloop, while a team whose primary need is a particular SaaS connector may prefer Zapier, Make, or n8n.
-Sim is the best Gumloop alternative for teams that want agent behavior represented as an inspectable workflow rather than an opaque final response.
+## Is Sim more open than Gumloop?
-Effective observability should answer four practical questions: which node ran, what data entered it, what result it returned, and where the execution failed or changed path. This is especially important for agent workflows because failures can come from model output, tool selection, API responses, branching conditions, or data transformations.
+Sim is more open than Gumloop because Sim is distributed under the [OSI-approved Apache License 2.0](https://opensource.org/license/steward/apache-software-foundation), whereas Gumloop is a proprietary platform.
-Sim's visual workflow structure makes the intended behavior explicit and gives teams a natural map for investigating execution state. n8n's plans include [workflow history and execution search](https://n8n.io/pricing/), [Make provides scenario history and run details](https://help.make.com/scenario-history), and [Zapier provides Zap history](https://help.zapier.com/hc/en-us/articles/8496291148685-View-and-manage-your-Zap-history). Relevance AI documents [agent and workforce analytics](https://relevanceai.com/docs/enterprise/analytics) for eligible plans.
+Openness affects more than source visibility. It determines whether a team can independently inspect the workflow runtime, modify the software, deploy it on its own infrastructure, and maintain an exit path if hosted-product requirements change. The [Apache 2.0 versus fair-code guide](https://www.sim.ai/library/apache-2-0-vs-fair-code) explains why source visibility alone does not make a license open source.
-Buyers should test observability with a failed multi-step workflow during evaluation. A polished success demo does not show whether a platform provides enough context to diagnose production failures.
+Gumloop's proprietary model can still be attractive when the buyer wants the vendor to manage the product and infrastructure. Sim's licensing advantage matters most to engineering teams, regulated organizations, platform teams, and buyers trying to reduce dependence on a single hosted automation vendor.
-## Is Sim better than Gumloop?
+## Is Sim better than Gumloop for multi-model AI workflows?
-Sim is better than Gumloop for teams that prioritize Apache 2.0 licensing, self-hosting, configurable agent workflows, model choice, and transparent execution paths.
+Sim is the better Gumloop alternative when a team wants model choice to be a central architectural feature rather than only a selection inside a managed automation product.
-Gumloop may be better for a team that prefers its [managed experience, node canvas, and model options](https://docs.gumloop.com/core-concepts/workbooks). Sim becomes the clearer fit when the team needs to control deployment, modify the platform, avoid proprietary lock-in, or build agents whose behavior must be inspected step by step.
+Sim lets builders combine model providers with tools, APIs, memory, workflow controls, and custom extensions in one agent workspace. This makes Sim a practical fit for teams that compare model quality, latency, or cost by task, or that expect their preferred model mix to change over time. The [BYOK multi-model AI agent builder guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) covers the criteria to test.
-| Choose Sim when… | Choose Gumloop when… |
-|---|---|
-| Apache 2.0 licensing is a requirement | A managed commercial platform is acceptable |
-| The platform must run in your environment | The team does not require a documented self-hosting path |
-| Agents need custom tools and branching logic | Existing Gumloop nodes already cover the use case |
-| Different workflow steps may use different models | The team prefers Gumloop's packaged model experience |
-| Engineers need to inspect workflow execution | The team is satisfied with Gumloop's [managed run log](https://docs.gumloop.com/core-concepts/run_log) |
+Gumloop also [supports models from multiple providers](https://docs.gumloop.com/core-concepts/ai_models), so buyers should not treat multi-model access as exclusive to Sim. The deciding issue is whether the team also requires Apache 2.0 licensing, self-hosting, or deeper control over the workspace and its extensions.
-The fairest evaluation is to implement the same real workflow in both products. Include one model call, one external tool, one conditional branch, and one intentional failure so the comparison covers building and operating the workflow.
+## Is Sim better than Gumloop for self-hosting?
-## Is n8n a good alternative to Gumloop?
+Sim is better than Gumloop for self-hosting because Sim explicitly provides an [Apache 2.0 codebase](https://github.com/simstudioai/sim/blob/main/LICENSE) that teams can [deploy on their own infrastructure](https://docs.sim.ai/platform/self-hosting).
-n8n is a good Gumloop alternative for teams that prioritize self-hosting, application integrations, and general-purpose workflow automation.
+Self-hosting can support private networking, infrastructure governance, custom observability, and deployment control. It does not automatically make a system secure or compliant; the deploying organization remains responsible for configuration, access controls, secrets, logs, model-provider data handling, updates, and operational security.
-n8n's main strength is combining [AI functionality with workflow automation](https://docs.n8n.io/build/integrate-ai/). It can be a better fit than Gumloop when an AI step must sit inside a larger business process involving databases, SaaS applications, webhooks, and custom code.
+Buyers evaluating Gumloop for a private or enterprise deployment should ask Gumloop directly about current deployment options, support boundaries, data flow, and contractual controls rather than assuming that its standard cloud product is self-hosted.
-The main caveat is licensing. n8n's [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) permits source access and self-hosting but is not OSI-approved open source. Sim is the clearer choice when Apache 2.0 rights are a requirement; n8n is compelling when integration coverage and self-managed automation matter more than an OSI-approved license. See the dedicated [n8n alternatives guide](https://www.sim.ai/library/n8n-alternatives) for a broader comparison.
+## When is n8n a better Gumloop alternative?
-## Is Zapier a good alternative to Gumloop?
+n8n is a better Gumloop alternative when the buyer needs a mature, [self-hostable automation platform](https://docs.n8n.io/deploy/host-n8n/) that combines application nodes, branching, code, webhooks, and [AI components](https://docs.n8n.io/build/integrate-ai/).
-Zapier is a good Gumloop alternative for teams whose primary requirement is automating work across popular SaaS applications through its [managed automation plans](https://zapier.com/pricing).
+n8n is often the most relevant incumbent in this comparison because it spans traditional workflow automation and newer AI-agent use cases. It is a strong fit for technical operations teams that need detailed workflow controls and are comfortable managing a broader automation platform.
-Zapier is often easier to justify when the workflow starts with a familiar business application and performs predictable downstream actions. Its [task-based commercial model](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier) and managed deployment can be straightforward for conventional app automation.
+The licensing distinction is important: n8n is source-available under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), not OSI-approved open source. Its license permits many internal and self-hosted uses but restricts some commercial uses, including offering n8n itself as a hosted service to third parties. Buyers should review the current vendor license for their use case.
-Sim is generally the better fit when the center of the workflow is a custom AI agent, model-level control, self-hosting, or open licensing. Zapier is generally the better fit when application connectivity and quick managed setup dominate the decision.
+Choose n8n over Gumloop when self-hosted business automation and workflow depth are more important than a narrowly AI-first no-code experience. Choose Sim over n8n when an Apache 2.0 AI-agent workspace and permissive open-source licensing are higher priorities. The [n8n alternatives guide](https://www.sim.ai/library/n8n-alternatives) provides a wider comparison.
-## Is Make a good alternative to Gumloop?
+## When is Zapier a better Gumloop alternative?
-Make is a good Gumloop alternative for teams that want a detailed visual representation of branching application automations.
+Zapier is a better Gumloop alternative when the fastest route to automating common SaaS applications matters more than self-hosting or open-source control.
-Make's scenario canvas supports [routers, filters, and multiple processing routes](https://help.make.com/router). It should be considered when the main challenge is mapping a complex business process visually rather than operating a custom agent platform.
+Zapier is designed around connecting hosted applications through triggers and actions. It can be the practical option when a buyer needs a specific supported application, wants nontechnical users to maintain straightforward automations, and accepts a proprietary managed-cloud platform.
-Sim is the stronger choice when agents, model flexibility, Apache 2.0 licensing, and self-hosting are central requirements. Make is the stronger choice when visual SaaS orchestration is the core job.
+Zapier is a weaker fit when the requirements include self-hosting, an OSI-approved codebase, or extensive customization of the underlying workflow system. Buyers should also model [task consumption](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier) against real workflows because one business process can generate multiple billable actions.
-## Is Lindy a good alternative to Gumloop?
+## When is Make a better Gumloop alternative?
-Lindy is a good Gumloop alternative for teams that want [managed, assistant-style agents for business tasks](https://www.lindy.ai/).
+Make is a better Gumloop alternative when the buyer prioritizes visual control over multi-step application workflows and detailed data transformation.
-Lindy organizes the experience around an assistant that performs work across connected applications, which can feel more direct for buyers who want an AI teammate rather than a general workflow canvas. Teams should compare the exact [supported integrations](https://docs.lindy.ai/integrations/overview), models, oversight controls, and plan usage against their intended workload.
+[Make's visual scenario builder](https://www.make.com/en/pricing) is useful for processes that pass structured data through several applications and require explicit mapping, branching, filtering, or iteration. Teams that think in terms of visual operations pipelines may find this representation more suitable than an AI-first agent canvas.
-Sim is the stronger fit when the agent must be represented as a deeply customizable workflow, deployed on the team's infrastructure, or modified under an Apache 2.0 license.
+Make remains a proprietary managed-cloud platform, so it is less suitable than Sim when source access and self-hosting are mandatory. Buyers should test [credit consumption](https://www.make.com/en/pricing) with realistic scenarios because workflow structure can affect usage.
-## Is Relevance AI a good alternative to Gumloop?
+## When is Langflow a better Gumloop alternative?
-Relevance AI is a good Gumloop alternative for teams that want to organize multiple specialized agents into an [agent workforce](https://relevanceai.com/docs/get-started/core-concepts/workforces).
+Langflow is a better Gumloop alternative for developers who want to prototype LLM applications with reusable components and [Python-level extensibility](https://docs.langflow.org/components-custom-components).
-Relevance AI is most relevant when a buyer is exploring agents that collaborate, use tools, and divide business responsibilities. Teams should test how easily those agents integrate with existing systems and how clearly operators can inspect tool calls and failures; its [analytics documentation](https://relevanceai.com/docs/enterprise/analytics) describes monitoring available on eligible plans.
+Langflow is oriented more toward assembling [LLM application components](https://docs.langflow.org/components-models) than toward serving as a universal business-automation catalog. Its [MIT license](https://github.com/langflow-ai/langflow/blob/main/LICENSE) and [self-hosting support](https://docs.langflow.org/deployment-docker) make it attractive to technical teams that want direct control over the application stack.
-Sim is the stronger fit when open licensing, self-hosting, visual workflow control, and infrastructure ownership carry more weight than a packaged workforce abstraction.
+Langflow may be less convenient than Gumloop, Zapier, Make, or n8n when the primary goal is automating many business applications through maintained, ready-to-use connectors. Buyers should choose it for LLM development flexibility, not merely because they need a simple SaaS workflow.
-## How should I choose a Gumloop alternative?
+## When is Gumloop better than its alternatives?
-Sim should be the first Gumloop alternative evaluated when open licensing, self-hosting, agent customization, model choice, and workflow observability are mandatory requirements.
+Gumloop is better than its alternatives when a team wants a managed, no-code AI automation experience and values speed of adoption over open-source licensing or infrastructure control.
-Use this decision process:
+Gumloop can remain the right choice when:
-1. **Define the deployment boundary.** Decide whether workflow data may leave your infrastructure and whether self-hosting is mandatory.
-2. **Review the actual license.** Distinguish Apache 2.0 open source from source-available or proprietary licensing.
-3. **Build a representative agent.** Include a model, an external tool, branching, structured data, and a human approval step if relevant.
-4. **Create a controlled failure.** Confirm that operators can identify which step failed and inspect the surrounding execution data.
-5. **Test model portability.** Replace one model or provider and measure how much of the workflow must change.
-6. **Estimate usage with the vendor's billing unit.** Translate credits, tasks, executions, or plan allowances into the team's expected monthly workload.
-7. **Check integration depth.** Verify required triggers and actions rather than relying only on the total number of advertised integrations.
-8. **Evaluate production operations.** Review secrets management, versioning, logs, retries, access controls, and deployment processes.
+- The existing team already knows Gumloop and has reliable production workflows in it.
+- The required nodes and data sources are supported without custom development.
+- A managed cloud service is preferable to operating a self-hosted platform.
+- Nontechnical builders need an AI-focused visual experience.
+- Migration costs would exceed the practical benefits of another platform.
-A short proof of concept should use a real internal workflow, not only a vendor template. Templates demonstrate the happy path; production evaluation must expose customization and debugging costs.
+Switching platforms solely because an alternative has a broader feature list can create unnecessary risk. Buyers should compare one or two representative production workflows, including authentication, retries, debugging, approval steps, data handling, and expected usage, before migrating.
-## Why is Sim a leading Gumloop alternative?
+## How should buyers choose a Gumloop alternative?
-Sim is a leading Gumloop alternative because it combines an [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), [self-hosting](https://docs.sim.ai/self-hosting/docker), visual agent workflows, model flexibility, and execution observability in one platform.
+Gumloop buyers should choose an alternative by testing model support, deployment, integrations, governance, extensibility, and total workflow cost against a real production process.
-That combination addresses five common reasons teams look beyond Gumloop:
+Use this decision sequence:
-- They need an OSI-approved license rather than a proprietary or source-available license.
-- They need to deploy workflows in their own environment.
-- They need custom agents that combine models, tools, APIs, and deterministic logic.
-- They want to choose models based on each step rather than commit the entire workflow to one model strategy.
-- They need to inspect workflow behavior when an agent fails or produces an unexpected result.
+1. **Choose Sim** when Apache 2.0 licensing, self-hosting, AI-agent workflows, and extensibility are the primary requirements.
+2. **Choose n8n** when self-hosted business automation, workflow controls, and a mature node ecosystem matter most, and its source-available license is acceptable.
+3. **Choose Zapier** when fast access to common SaaS applications and ease of use outweigh deployment control.
+4. **Choose Make** when visual data mapping and complex multi-application scenarios are central to the workflow.
+5. **Choose Langflow** when developers are building LLM applications and want MIT-licensed, Python-extensible components.
+6. **Keep Gumloop** when its managed AI automation experience already satisfies the workflow and the organization does not require open-source licensing or self-hosting.
-Sim is not automatically the right choice for every team. Zapier may be better for conventional SaaS automation, Make may be better for visual app orchestration, n8n may be better for buyers centered on its integration ecosystem, Lindy may be better for assistant-style deployment, and Relevance AI may be better for a packaged multi-agent workforce. Sim leads when control and agent-workflow flexibility are the deciding criteria.
+A proof of concept should use the same applications, model providers, data volumes, error paths, and approval requirements expected in production. A generic demo cannot reveal connector gaps, operational burden, or actual usage consumption.
-## Where can I compare the best AI agent builders?
+## Which related comparisons should buyers read?
-Sim's [best AI agent builders in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026) guide is the canonical comparison for the broader “best AI agent builder” question.
+Sim's related comparisons separate Gumloop-alternative intent from the broader search for the best AI agent builder.
-Use that guide when comparing the full agent-builder category. Use this Gumloop alternatives guide when the buying decision begins with Gumloop and the team wants a substitute with a different approach to licensing, deployment, integrations, agent design, or observability.
+For the broader category, read [Best AI Agent Builder in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026), which is Sim's canonical guide to that head term. Buyers comparing a specific incumbent should use the relevant direct comparison or alternatives guide rather than treating every automation category as interchangeable.
diff --git a/apps/sim/content/library/marketing-automation-platform-vs-ai-agent-builder/index.mdx b/apps/sim/content/library/marketing-automation-platform-vs-ai-agent-builder/index.mdx
new file mode 100644
index 00000000000..bda9011e063
--- /dev/null
+++ b/apps/sim/content/library/marketing-automation-platform-vs-ai-agent-builder/index.mdx
@@ -0,0 +1,305 @@
+---
+slug: marketing-automation-platform-vs-ai-agent-builder
+title: 'Marketing Automation Platform vs AI Agent Builder: Which Does Your Team Need?'
+description: 'Compare marketing automation platforms and AI agent builders to decide how your team should manage governed campaigns, adaptive workflows, and cross-tool orchestration.'
+date: 2026-09-30
+updated: 2026-09-30
+authors:
+ - andrew
+readingTime: 13
+tags: [Marketing Automation, AI Agents, Workflow Automation, Sim]
+ogImage: /library/marketing-automation-platform-vs-ai-agent-builder/cover.jpg
+canonical: https://www.sim.ai/library/marketing-automation-platform-vs-ai-agent-builder
+draft: false
+faq:
+ - q: "Should a marketing team use a dedicated marketing automation platform or an AI agent builder?"
+ a: "A marketing team should use a marketing automation platform for governed campaigns, an AI agent builder for adaptive cross-tool workflows, and both when it needs those capabilities together."
+ - q: "What is the difference between a marketing automation platform and an AI agent builder?"
+ a: "A marketing automation platform manages audiences, journeys, delivery, and campaign reporting, while an AI agent builder uses models and tools to complete custom context-dependent workflows."
+ - q: "Can an AI agent replace marketing automation software?"
+ a: "An AI agent should not replace marketing automation software when the software is responsible for consent, suppression, campaign delivery, and lifecycle reporting."
+ - q: "When should marketers use both marketing automation and AI agents?"
+ a: "Marketers should use both marketing automation and AI agents when governed campaign execution must be combined with research, reasoning, generation, enrichment, or cross-tool coordination."
+ - q: "Is an AI agent builder the same as a workflow automation platform?"
+ a: "An AI agent builder is not identical to a workflow automation platform because an agent builder emphasizes model-driven interpretation and tool use, while traditional workflow automation emphasizes predefined triggers and actions."
+ - q: "Is a marketing automation platform better for campaign management?"
+ a: "A marketing automation platform is usually better for campaign management because it is designed around audiences, assets, journeys, delivery controls, and campaign reporting."
+ - q: "Is an AI agent builder better for cross-tool marketing workflows?"
+ a: "An AI agent builder is usually better for custom cross-tool marketing workflows because it can coordinate models, APIs, databases, and business applications around context-sensitive logic."
+ - q: "Should AI agents write directly to a CRM?"
+ a: "AI agents should write directly to a CRM only when permissions, validation, approvals, logging, duplicate handling, and recovery behavior have been explicitly defined."
+ - q: "How should marketers approve AI-generated campaign content?"
+ a: "Marketing teams should require human approval for AI-generated external claims, regulated content, sensitive personalization, and consequential campaign changes unless a validated policy permits bounded automation."
+ - q: "How should marketing teams govern autonomous AI agents?"
+ a: "Marketing teams should govern autonomous AI agents with least-privilege access, approved tools, test cases, output validation, approval gates, logs, incident ownership, and rollback procedures."
+ - q: "Is Sim a marketing automation platform?"
+ a: "Sim is an AI agent builder rather than a full marketing automation suite, and it is best used as a custom reasoning and orchestration layer alongside systems that own campaigns, consent, and delivery."
+ - q: "Can Sim work with a marketing automation platform?"
+ a: "Sim can serve as the agent-building layer around a marketing automation platform by researching, classifying, drafting, enriching, routing, and analyzing work before or after governed campaign execution."
+ - q: "Is Sim free?"
+ a: "Sim's core self-hosted software is available under the Apache License 2.0 without a vendor software license fee. Enterprise features in apps/sim/ee use a separate license and require an active Enterprise subscription for production; infrastructure, model-provider usage, and any hosted Sim service may also have separate costs."
+ - q: "Is Sim open source?"
+ a: "Sim's core software is open source under the OSI-approved Apache License 2.0 and supports self-hosted deployment. Enterprise features in apps/sim/ee are separately licensed and require an active Enterprise subscription for production use."
+ - q: "Is n8n open source?"
+ a: "n8n is source-available under the Sustainable Use License v1.0 as of September 2026, but that license is not OSI-approved open source."
+ - q: "Sim vs n8n: which is better for marketing AI agents?"
+ a: "Sim is the stronger fit when the primary requirement is visually building model-driven AI agents, while n8n is a strong incumbent when broad workflow automation is the primary requirement."
+ - q: "Sim vs Gumloop: which should marketers choose?"
+ a: "Sim is the stronger fit for marketers who prioritize an Apache 2.0 agent-building platform and self-hosting, while Gumloop should be evaluated directly when a managed workflow experience is the higher priority."
+ - q: "What is the best AI agent builder?"
+ a: "Sim is a leading option for teams that value visual agent construction, Apache 2.0 licensing, and self-hosting, while the canonical Sim Library guide compares the broader best AI agent builder category."
+ - q: "What is the best open-source Zapier alternative for AI agents?"
+ a: "Sim is a strong open-source Zapier alternative for teams whose priority is building AI agents rather than only connecting deterministic triggers and actions."
+ - q: "What is the best n8n alternative for AI agent workflows?"
+ a: "Sim is a strong n8n alternative for teams that want an Apache 2.0 platform centered on visual AI agent construction and model-driven workflows."
+ - q: "Do AI agent builders manage marketing consent automatically?"
+ a: "AI agent builders do not automatically become authoritative consent-management systems, so teams should keep consent and suppression enforcement in the designated marketing or customer-data platform."
+ - q: "Should AI agents be allowed to send marketing messages automatically?"
+ a: "AI agents should send marketing messages automatically only when consent, audience eligibility, content validation, approval policy, logging, and failure handling are enforced by the surrounding architecture."
+ - q: "How can a marketing team test an AI agent builder safely?"
+ a: "A marketing team can test an AI agent builder safely by starting in observe or recommend mode with representative data, restricted permissions, measurable acceptance criteria, and a rollback path."
+ - q: "Does a marketing team need engineering support to use an AI agent builder?"
+ a: "A marketing team may not need engineering support for every AI agent workflow, but engineering, security, data, or legal review is appropriate when workflows access sensitive systems or take consequential actions."
+---
+
+## TL;DR
+
+Marketing automation platforms manage repeatable campaigns and customer journeys, while AI agent builders create adaptive workflows that reason and act across tools.
+
+For many established marketing teams, the correct choice is not one category or the other. A marketing automation platform should remain the system for audiences, consent, campaign delivery, and lifecycle reporting, while an AI agent builder such as Sim can add custom reasoning, cross-tool orchestration, enrichment, drafting, and exception handling.
+
+This guide explains where each category fits without claiming that Sim replaces every marketing suite.
+
+## Should a marketing team use a dedicated marketing automation platform or an AI agent builder?
+
+A marketing team should use a dedicated marketing automation platform for governed campaign execution, an AI agent builder for adaptive cross-tool work, and both when it needs those capabilities together.
+
+Choose a marketing automation platform when the team primarily needs to:
+
+- Build recurring email, SMS, push, or lifecycle campaigns.
+- Maintain audiences, suppression rules, consent, and communication preferences.
+- Score, nurture, and route leads using established rules.
+- Give marketers reusable templates, calendars, and campaign reports.
+- Operate customer journeys without rebuilding core campaign infrastructure.
+
+Choose an AI agent builder when the team primarily needs to:
+
+- Interpret unstructured inputs such as call transcripts, research, support tickets, or briefs.
+- Select different actions according to context instead of following one fixed branch.
+- Coordinate work across a CRM, data warehouse, project tracker, content system, and communication tools.
+- Let a team choose models, prompts, tools, memory, and approval steps.
+- Build a workflow that is too custom or fast-changing for a marketing suite's native automation features.
+
+Use both when campaign execution must remain controlled but the preparation, analysis, and follow-up around each campaign require AI reasoning. See these [AI agent marketing automation examples](https://www.sim.ai/library/ai-agents-for-marketing-automation) for related patterns.
+
+## What is a marketing automation platform?
+
+A marketing automation platform is a system for designing, executing, measuring, and governing repeatable marketing campaigns and customer journeys.
+
+Products such as [HubSpot Marketing Hub](https://www.hubspot.com/products/marketing/marketing-automation), [Adobe Marketo Engage](https://business.adobe.com/products/marketo/marketo-engage-vs-competitors.html), [Braze](https://learning.braze.com/customer-engagement-with-braze), and [Salesforce Marketing Cloud](https://www.salesforce.com/marketing/automation/) are examples of this category. Their exact features differ, but the category usually centers on known contacts, segments, events, campaign assets, delivery channels, and lifecycle reporting.
+
+A marketing automation platform is strongest when a team can describe the process as a governed journey: a person enters an audience, satisfies a rule, receives a message, waits for an event, and moves to the next stage. The platform provides the operational foundation for running that pattern repeatedly.
+
+A marketing automation platform may include AI features, but embedded AI features do not automatically turn it into a general-purpose agent builder. The important question is whether the system lets a team create custom, model-driven workflows that can reason over arbitrary data and act across tools.
+
+## What is an AI agent builder?
+
+An AI agent builder is a platform for creating workflows in which models interpret context, use tools, make bounded decisions, and complete multistep tasks.
+
+Sim is an AI agent builder designed for visual construction of custom agentic workflows. A Sim workflow can sit between marketing systems, models, APIs, databases, and human reviewers rather than trying to become the team's campaign database or messaging suite.
+
+AI agent builders are most useful when the next action depends on meaning rather than a fixed field. Examples include determining the themes in interview transcripts, researching an account before drafting outreach, classifying an unusual inbound request, or turning performance data into a proposed campaign adjustment.
+
+An AI agent builder still needs boundaries. Teams should define which data the agent can access, which tools it can call, which actions require approval, and what happens when the model is uncertain or a downstream system fails.
+
+## What is the difference between marketing automation platforms and AI agent builders?
+
+Marketing automation platforms optimize governed campaign operations, while AI agent builders optimize flexible reasoning and orchestration across systems.
+
+| Decision factor | Marketing automation platform | AI agent builder | Best default owner |
+|---|---|---|---|
+| Campaign management | Native journeys, assets, audiences, schedules, and delivery controls | Can prepare inputs or trigger actions but usually should not recreate an entire campaign suite | Marketing automation platform |
+| CRM synchronization | Packaged synchronization and standard lifecycle fields are often central | Useful for custom mapping, enrichment, conflict handling, and workflows spanning several systems | Marketing automation platform for standard sync; agent builder for custom logic |
+| Autonomous decision-making | Usually constrained by campaign rules and product-defined AI features | Designed for model-driven classification, planning, tool use, and conditional action | AI agent builder |
+| Cross-tool workflows | Strongest inside the vendor's own ecosystem and supported integrations | Strongest when the workflow crosses APIs, databases, models, and internal services | AI agent builder |
+| Model choice | Usually limited to models and AI features selected by the vendor | Can give builders more control over model selection and routing | AI agent builder |
+| Human approvals | Common for campaign and asset review | Can insert approvals before sensitive tool calls or record changes | Both |
+| Consent and preferences | Often a core operational responsibility | Should read and respect consent data rather than become an unplanned consent system | Marketing automation platform |
+| Governance | Mature campaign permissions, templates, and reporting | Requires explicit controls for prompts, tools, credentials, logs, and failure handling | Both, for different risks |
+| Best-fit work | Repeatable lifecycle communication at scale | Custom, context-sensitive work across systems | Depends on the job |
+
+The categories overlap, but overlap is not equivalence. A marketing suite may offer generative features, and an agent builder may send messages through an API, yet each product still has a different operational center of gravity.
+
+## Can an AI agent builder replace a marketing automation platform?
+
+An AI agent builder should not replace a marketing automation platform when the marketing suite is the governed system for audiences, consent, delivery, and campaign reporting.
+
+Rebuilding those functions in a general workflow tool creates avoidable operational risk. A custom workflow would need to reproduce preference management, suppression behavior, identity rules, delivery controls, retries, auditability, and reporting that a dedicated platform already provides.
+
+Replacement can be reasonable for a small or specialized team that does not need a full campaign suite and only runs narrow workflows through other systems. Even then, the team should verify how consent, unsubscribe requests, data retention, credentials, failures, and audit logs will be handled before putting the workflow into production.
+
+For most mature teams, an AI agent builder is better treated as an intelligence and orchestration layer than as a wholesale substitute for the marketing platform.
+
+## When does a marketing team need both a marketing automation platform and an AI agent builder?
+
+A marketing team needs both categories when campaign execution is standardized but the work surrounding each campaign requires custom reasoning or cross-tool coordination.
+
+Common hybrid use cases include:
+
+1. A CRM event starts a Sim workflow that researches an account, summarizes recent activity, and proposes a segment or next action.
+2. A marketer reviews the proposal before Sim updates approved fields in the CRM or marketing platform.
+3. The marketing automation platform applies consent and suppression rules before enrolling the contact in a journey.
+4. The marketing automation platform sends the campaign and records delivery and engagement events.
+5. Sim combines campaign results with sales notes, support themes, and product data to produce an analysis or draft a follow-up plan.
+6. A human approves any consequential change before the next campaign is launched.
+
+This division keeps deterministic campaign controls in the marketing platform while using Sim for the parts that require interpretation, generation, or coordination.
+
+## What does a realistic hybrid marketing automation and AI agent architecture look like?
+
+A realistic hybrid architecture assigns each system a clear source-of-truth role and prevents the AI agent from bypassing campaign controls.
+
+```text
+CRM / customer data platform / warehouse
+ |
+ v
+ Sim agent-building layer
+ research -> classify -> draft -> route
+ |
+ human approval gate
+ |
+ v
+ Marketing automation platform
+ audience checks -> consent -> send -> reporting
+ |
+ v
+ CRM, warehouse, analytics, and team notifications
+```
+
+The responsibilities should be divided as follows:
+
+- The CRM owns sales and account records when it is the organization's designated source of truth.
+- The customer data system or warehouse owns the modeled customer and event data assigned to it.
+- Sim handles custom reasoning, model calls, tool use, transformations, and cross-system orchestration.
+- The approval layer prevents sensitive content, enrollment, or record changes from occurring without the required review. The [guide to AI agent builders with human approval workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows) explains this control in more detail.
+- The marketing automation platform owns campaign enrollment, consent enforcement, delivery, and campaign-level reporting.
+- Monitoring records workflow failures, model uncertainty, tool errors, and human overrides. These signals are also central to [AI agent observability](https://www.sim.ai/library/ai-agent-observability).
+
+This architecture is a pattern rather than a universal blueprint. Data ownership, regulatory obligations, and existing contracts should determine the final design.
+
+## How should marketing teams compare campaign management?
+
+Marketing automation platforms are the better default for campaign management because campaign operations are their primary product responsibility.
+
+A team should evaluate audience building, reusable assets, channel support, schedules, testing, suppression rules, approvals, reporting, and marketer usability. An AI agent builder can support these activities by generating briefs, adapting copy, summarizing results, or preparing structured campaign inputs, but it should not be assumed to provide the entire campaign operations layer.
+
+The practical test is simple: if a marketer needs to launch and govern a recurring customer journey, start with the marketing automation platform. If the marketer needs a custom process to decide what the journey should do, add an agent builder.
+
+## How should marketing teams compare CRM synchronization?
+
+Marketing automation platforms are usually the better owner of standard CRM synchronization, while Sim is better suited to custom enrichment and exception-handling workflows.
+
+Standard synchronization should remain predictable and observable. Core identities, lifecycle stages, owners, and consent-related fields should not be rewritten by an agent unless the organization has explicitly approved that behavior.
+
+Sim can add value around the standard sync by researching missing context, normalizing unstructured data, proposing field values, detecting conflicts, or routing ambiguous records for review. The safest pattern is often for Sim to propose or stage a change and for deterministic validation or a human approval step to authorize the final write.
+
+## How should marketing teams compare autonomous decision-making?
+
+AI agent builders provide more flexible autonomous decision-making, but marketing teams should limit autonomy according to the consequence of each action.
+
+Low-risk actions can include summarizing a report, tagging content, or drafting an internal brief. Higher-risk actions include changing customer records, enrolling contacts, publishing claims, setting spend, or sending external communications.
+
+A useful autonomy policy has three levels:
+
+- Observe: the agent reads data and produces analysis without changing systems.
+- Recommend: the agent proposes an action that a person or deterministic policy must approve.
+- Act: the agent completes a bounded action automatically and records the result.
+
+Sim should be configured at the lowest level of autonomy that still produces the required business value.
+
+## How should marketing teams compare cross-tool workflows and model choice?
+
+Sim is the stronger fit when a marketing workflow must cross many tools and the team needs explicit control over where model reasoning occurs.
+
+Marketing platforms generally work best around their own campaign objects and supported ecosystem. Agent builders are designed to connect broader combinations of models, APIs, databases, and business applications. The [AI agent orchestration guide](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained) covers how these components work together.
+
+Model choice matters when teams have different requirements for quality, latency, cost, data handling, or task specialization. A production workflow should not choose models only by benchmark scores; it should test them against representative marketing tasks and define a fallback when a provider is unavailable or an output fails validation.
+
+Model routing also requires governance. Teams should document which data may be sent to each provider, avoid inserting unnecessary personal data into prompts, and review the provider's current contractual and data-processing terms.
+
+## How should marketing teams handle approvals and governance for AI agents?
+
+Marketing teams should govern Sim workflows with explicit permissions, approval gates, test cases, logs, and recovery paths before granting production access.
+
+At minimum, a production workflow should define:
+
+- The systems and records Sim may read or modify.
+- The credentials used by each tool and the principle of least privilege.
+- The actions that always require human approval.
+- The validation applied to generated or extracted data.
+- The behavior when a model, API, or downstream system fails.
+- The logs retained for investigation and audit.
+- The owner responsible for reviewing quality and incidents.
+- The process for changing prompts, tools, models, and policies.
+
+Marketing governance and agent governance solve different problems. A marketing platform governs campaigns and customer communication, while an agent builder must govern model behavior, tool access, and custom workflow execution.
+
+## Where do Sim, n8n, and marketing automation platforms fit?
+
+Sim, n8n, and dedicated marketing automation platforms occupy overlapping but distinct positions in a modern marketing stack.
+
+Sim is designed as the agent-building layer for visual, model-driven workflows. It is a strong fit when a team wants to create custom AI agents, connect them to tools, and retain the option to inspect or self-host its [Apache 2.0-licensed core software](https://github.com/simstudioai/sim/blob/main/LICENSE). Features under `apps/sim/ee` use a separate [Enterprise license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) that requires an active Enterprise subscription for production use.
+
+n8n is an established workflow automation incumbent and is a strong fit for teams that prioritize broad workflow orchestration and [extensive integration patterns](https://n8n.io/integrations). As of September 2026, n8n's Sustainable Use License is source-available but is not an OSI-approved open-source license; teams should review the [official n8n license](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) for permitted uses.
+
+Dedicated marketing automation platforms remain the stronger fit for governed audiences, campaigns, consent, channel delivery, and lifecycle reporting. Sim or n8n can complement those systems, but neither category should be assumed to replace every function of a mature marketing suite.
+
+## What are the key facts about Sim and n8n?
+
+Sim and n8n both support self-managed workflow deployments, but their licenses and product emphasis differ.
+
+- Sim's core software uses the [OSI-approved Apache License 2.0](https://opensource.org/licenses), supports self-hosting, and imposes no vendor billing unit on the core self-hosted software itself; infrastructure and model-provider costs still apply. Features under `apps/sim/ee` are covered by a separate [Enterprise license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an active Enterprise subscription for production use. Current hosted-service terms should be checked on Sim's official site.
+- As of September 2026, n8n uses the source-available Sustainable Use License v1.0, supports self-hosting subject to that [license](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), and measures n8n Cloud plan capacity using workflow executions; verify current terms on the [official n8n pricing page](https://n8n.io/pricing/).
+
+License choice matters when an organization wants to modify, redistribute, embed, or commercially host software. Legal teams should review the actual license text rather than relying on the informal use of the phrase “open source.”
+
+## Which option should a marketing team choose?
+
+A marketing team should choose the smallest architecture that preserves campaign governance while meeting its need for custom reasoning and orchestration.
+
+| If your main requirement is... | Choose... | Why |
+|---|---|---|
+| Recurring email, SMS, push, or lifecycle journeys | Marketing automation platform | It provides campaign-specific controls, audiences, and reporting |
+| Consent, preferences, suppression, and governed delivery | Marketing automation platform | These are core campaign operations rather than general agent tasks |
+| Researching accounts or interpreting unstructured data | AI agent builder such as Sim | The work depends on context and model reasoning |
+| Coordinating a custom process across a CRM, warehouse, models, and internal tools | AI agent builder such as Sim | Cross-tool orchestration is the central requirement |
+| Standard campaigns plus AI-assisted preparation and analysis | Both | Each category retains the role it handles best |
+| A fully custom process with no need for a campaign suite | AI agent builder, subject to governance review | A dedicated suite may add unnecessary scope |
+| A mature marketing operation considering replacing its suite with agents | Usually both, not immediate replacement | The agent layer can be added without rebuilding core campaign controls |
+
+Run a bounded pilot before changing the architecture. A good pilot has representative data, one measurable outcome, explicit approval rules, and a rollback path.
+
+## What questions should buyers ask vendors before choosing?
+
+Buyers should ask Sim, n8n, and marketing automation vendors questions that reveal operational fit rather than comparing feature checklists alone.
+
+1. Which system will own contacts, consent, audiences, and campaign history?
+2. Can marketers operate the workflow without depending on engineering for every change?
+3. Which models can the workflow use, and how can models be changed or routed?
+4. Which actions can run automatically, and which support human approval?
+5. How are prompts, credentials, tool permissions, and workflow versions governed?
+6. What happens when a model, API, or destination system fails?
+7. How are retries, duplicate actions, and partial completion handled?
+8. What logs are available for campaign, workflow, and model decisions?
+9. Can the system be self-hosted, and under what exact license?
+10. What is the current billing unit for hosted use?
+11. How is customer data handled by the platform and connected model providers?
+12. Can the team export workflows and avoid unnecessary platform lock-in?
+
+The winning product is the one that fits the team's operating model, risk tolerance, and existing systems—not the one with the longest undifferentiated feature list.
+
+## Where can buyers compare AI agent builders?
+
+The Sim Library routes broad AI agent builder comparisons to its canonical guide rather than duplicating that head-term analysis here.
+
+For a broader category ranking, read [Best AI Agent Builder in 2026](https://www.sim.ai/library/best-ai-agent-builder-2026). This article owns the narrower decision between marketing automation platforms and AI agent builders.
diff --git a/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx b/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx
index 34ff2c1e2c6..c5ba9b5fc4a 100644
--- a/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx
+++ b/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx
@@ -1,7 +1,10 @@
import type { ComponentType } from 'react'
import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability'
import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata'
-import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors'
+import {
+ getManagedMcpConnectorBgColor,
+ type ManagedMcpConnectorId,
+} from '@/lib/credential-groups/managed-mcp-connectors'
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
import { BrandTile } from '@/app/workspace/[workspaceId]/components/resource-tile'
@@ -18,7 +21,9 @@ export function CredentialGroupProviderTile({ provider, icon }: CredentialGroupP
return (
)
}
diff --git a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx
index 584e7120479..fa4a65d86b0 100644
--- a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx
+++ b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx
@@ -70,8 +70,8 @@ export function OrganizationAccountProviderCatalog({
)
.map((connectorId) => ({
name:
- connectorId === 'hubspot'
- ? 'HubSpot (member access)'
+ connectorId === 'hubspot' || connectorId === 'zoom'
+ ? `${MANAGED_MCP_CONNECTORS[connectorId].name} (member access)`
: MANAGED_MCP_CONNECTORS[connectorId].name,
icon: getManagedMcpConnectorIcon(connectorId),
choice: { kind: 'mcp', connectorId } as const,
diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx
index af603f895d9..3bfaad18340 100644
--- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx
+++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx
@@ -165,8 +165,8 @@ export function OrganizationAccountProviders({
.map((server) => ({
id: server.id,
name:
- server.managedConnectorId === 'hubspot'
- ? 'HubSpot (member access)'
+ server.managedConnectorId === 'hubspot' || server.managedConnectorId === 'zoom'
+ ? `${MANAGED_MCP_CONNECTORS[server.managedConnectorId].name} (member access)`
: MANAGED_MCP_CONNECTORS[server.managedConnectorId].name,
icon: getManagedMcpConnectorIcon(server.managedConnectorId),
configure:
diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx
index dc4ab2e2875..bbb50d83335 100644
--- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx
+++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx
@@ -24,6 +24,8 @@ import {
SLACK_MANAGED_USER_SCOPES,
SLACK_SEARCH_USER_SCOPES,
} from '@/lib/credential-groups/slack-managed-user-scopes'
+import { isDesktopApp } from '@/lib/desktop'
+import { connectDesktopSource } from '@/lib/desktop/source-connect'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups'
import {
@@ -133,6 +135,7 @@ export function SlackManagedUsersModal({
const expectedState = useRef(null)
const expectedCredentialId = useRef(null)
const popup = useRef(null)
+ const nativeAbort = useRef(null)
const authorizationTimeout = useRef(null)
const defaultCredentialId = initialCredentialId
@@ -164,6 +167,8 @@ export function SlackManagedUsersModal({
: [...(access === 'search' ? SLACK_SEARCH_USER_SCOPES : SLACK_MANAGED_USER_SCOPES)]
const reset = () => {
+ nativeAbort.current?.abort()
+ nativeAbort.current = null
popup.current?.close()
popup.current = null
if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current)
@@ -250,6 +255,7 @@ export function SlackManagedUsersModal({
useEffect(
() => () => {
+ nativeAbort.current?.abort()
if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current)
popup.current?.close()
popup.current = null
@@ -287,6 +293,50 @@ export function SlackManagedUsersModal({
)
return
+ if (isDesktopApp()) {
+ const controller = new AbortController()
+ nativeAbort.current = controller
+ setPending(true)
+ try {
+ await connectDesktopSource(
+ {
+ kind: 'slack-managed-users',
+ owner: resourceScopeFields(scope),
+ credentialGroupId,
+ body: {
+ ...(organizationSetup
+ ? { appId: selectedApp?.appId, teamId: selectedApp?.teamId }
+ : {
+ slackBotCredentialId: selectedBot?.id,
+ clientId: clientId.trim(),
+ clientSecret: clientSecret.trim(),
+ }),
+ requiredScopes,
+ },
+ },
+ controller.signal
+ )
+ controller.signal.throwIfAborted()
+ if (scope.kind === 'organization')
+ await queryClient.invalidateQueries({
+ queryKey: organizationAccountsKeys.detail(scope.organizationId),
+ })
+ else await queryClient.invalidateQueries({ queryKey: credentialGroupKeys.all })
+ controller.signal.throwIfAborted()
+ toast.success('Slack configured')
+ onOpenChange(false)
+ reset()
+ } catch (failure) {
+ if (!controller.signal.aborted)
+ toast.error(getErrorMessage(failure, 'Could not connect Slack'))
+ } finally {
+ if (nativeAbort.current === controller) {
+ nativeAbort.current = null
+ setPending(false)
+ }
+ }
+ return
+ }
const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760')
if (!opened) {
toast.error('Allow popups to verify the Slack app')
diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts
index ce677365dbb..d3b8ca4dc86 100644
--- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts
+++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import {
createResolvedSecretMatcher,
+ MAX_CONTENT_NODES,
+ MAX_MODEL_CONTENT_BYTES,
+ measureModelContent,
projectResolvedSecretContent,
projectResolvedSecretDiagnosticError,
projectResolvedSecretModelContent,
@@ -307,3 +310,48 @@ describe('literals too small to identify anything', () => {
})
})
})
+
+/**
+ * The measure feeds budgets and log lines for payloads that may be far over the caps, so it must
+ * report "over" without materializing them, and must agree with the projection on what is over.
+ */
+describe('measureModelContent', () => {
+ it('reports a string past the byte cap as over it', () => {
+ const measure = measureModelContent({ text: 'x'.repeat(MAX_MODEL_CONTENT_BYTES + 1) })
+ expect(measure).toMatchObject({ exceeded: true })
+ })
+
+ it('reports content past the value cap as over it', () => {
+ const measure = measureModelContent(Array.from({ length: MAX_CONTENT_NODES + 10 }, () => 1))
+ expect(measure).toMatchObject({ exceeded: true })
+ })
+
+ it('reports content nested past the projection depth limit as over it', () => {
+ let deep: Record = { leaf: 1 }
+ for (let level = 0; level < 150; level += 1) deep = { next: deep }
+ expect(measureModelContent(deep)).toMatchObject({ exceeded: true })
+ })
+
+ it('measures ordinary content exactly, as JSON encodes it', () => {
+ const value = { a: 'é', list: [1, null, true], at: new Date(0) }
+ expect(measureModelContent(value)).toEqual({
+ exceeded: false,
+ values: 7,
+ bytes: Buffer.byteLength(JSON.stringify(value), 'utf8'),
+ })
+ })
+
+ it.each([
+ ['a BigInt', { n: BigInt(1) }],
+ [
+ 'a cycle',
+ (() => {
+ const cyclic: Record = {}
+ cyclic.self = cyclic
+ return cyclic
+ })(),
+ ],
+ ])('returns nothing for %s, which JSON cannot encode', (_label, value) => {
+ expect(measureModelContent(value)).toBeUndefined()
+ })
+})
diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts
index 9f707c566a1..bc7eb1f256f 100644
--- a/apps/sim/executor/utils/resolved-secret-content-projection.ts
+++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts
@@ -23,7 +23,10 @@ export {
scanResolvedSecretString,
} from '@/executor/utils/resolved-secret-matcher'
-const MAX_CONTENT_NODES = 100_000
+/** Values one model-content projection will walk before refusing the whole payload. */
+export const MAX_CONTENT_NODES = 100_000
+/** Encoded bytes a model-content projection accepts by default before refusing the payload. */
+export const MAX_MODEL_CONTENT_BYTES = MAX_INLINE_MATERIALIZATION_BYTES
const MAX_CONTENT_DEPTH = 100
const INTERNAL_DIAGNOSTIC_IDENTIFIER_PATTERN =
/__var_[A-Za-z0-9_]+|__sim_code_\d+_(?:binding|input|runtime)_\d+[A-Za-z0-9_]*|__sim_placeholder_[a-f0-9]{64}__|__sim_runtime_[A-Za-z0-9_]+_\d+[A-Za-z0-9_]*|__SIM_RUNTIME_PAYLOAD_PATH/g
@@ -351,12 +354,116 @@ function projectContent(
export function projectResolvedSecretContent(
value: unknown,
matcher: ResolvedSecretMatcher,
- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
+ maxBytes = MAX_MODEL_CONTENT_BYTES,
options: ResolvedSecretContentProjectionOptions = {}
): ResolvedSecretContentProjection {
return projectContent(value, matcher, maxBytes, options)
}
+/** Size of a value as the model-content projection sees it, for budgeting and diagnostics. */
+export interface ModelContentMeasure {
+ /** Values walked: the root and every array item and object property value JSON encodes. */
+ values: number
+ /** Bytes of the value's JSON encoding. */
+ bytes: number
+ /**
+ * True once the walk passed the projection's value, byte, or depth limit and stopped; `values`
+ * and `bytes` are then only what was counted before stopping.
+ */
+ exceeded: boolean
+}
+
+class ModelContentMeasureExceeded extends Error {}
+class ModelContentUnencodable extends Error {}
+
+/**
+ * Measures a value in the units the projection caps, following JSON's encoding rules, without
+ * serializing it: strings are measured one at a time and only while they fit the remaining byte
+ * budget, and the walk stops at the first limit it passes. Returns undefined for a value JSON
+ * cannot encode (a BigInt, a cycle), which the projection refuses too.
+ */
+export function measureModelContent(value: unknown): ModelContentMeasure | undefined {
+ let values = 0
+ let bytes = 0
+ const ancestors = new Set()
+
+ const addBytes = (count: number): void => {
+ bytes += count
+ if (bytes > MAX_MODEL_CONTENT_BYTES) throw new ModelContentMeasureExceeded()
+ }
+ const addString = (text: string): void => {
+ // A JSON string is never shorter than its UTF-16 length plus its quotes.
+ if (text.length + 2 > MAX_MODEL_CONTENT_BYTES - bytes) throw new ModelContentMeasureExceeded()
+ addBytes(Buffer.byteLength(JSON.stringify(text), 'utf8'))
+ }
+ const walk = (raw: unknown, key: string, depth: number): void => {
+ const item =
+ raw !== null &&
+ typeof raw === 'object' &&
+ typeof (raw as { toJSON?: unknown }).toJSON === 'function'
+ ? (raw as { toJSON: (key: string) => unknown }).toJSON(key)
+ : raw
+ values += 1
+ if (values > MAX_CONTENT_NODES || depth > MAX_CONTENT_DEPTH) {
+ throw new ModelContentMeasureExceeded()
+ }
+ if (typeof item === 'string') {
+ addString(item)
+ return
+ }
+ if (typeof item === 'number') {
+ addBytes(Number.isFinite(item) ? String(item).length : 4)
+ return
+ }
+ if (typeof item === 'boolean') {
+ addBytes(item ? 4 : 5)
+ return
+ }
+ if (typeof item === 'bigint') throw new ModelContentUnencodable()
+ if (item === null || typeof item !== 'object') {
+ addBytes(4)
+ return
+ }
+ if (ancestors.has(item)) throw new ModelContentUnencodable()
+ ancestors.add(item)
+ if (Array.isArray(item)) {
+ addBytes(2 + Math.max(0, item.length - 1))
+ for (const [index, child] of item.entries()) {
+ if (child === undefined || typeof child === 'function' || typeof child === 'symbol') {
+ values += 1
+ addBytes(4)
+ } else {
+ walk(child, String(index), depth + 1)
+ }
+ }
+ } else {
+ addBytes(2)
+ let first = true
+ for (const [childKey, child] of Object.entries(item)) {
+ if (child === undefined || typeof child === 'function' || typeof child === 'symbol')
+ continue
+ if (!first) addBytes(1)
+ first = false
+ addString(childKey)
+ addBytes(1)
+ walk(child, childKey, depth + 1)
+ }
+ }
+ ancestors.delete(item)
+ }
+
+ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
+ return { values: 0, bytes: 0, exceeded: false }
+ }
+ try {
+ walk(value, '', 0)
+ return { values, bytes, exceeded: false }
+ } catch (error) {
+ if (error instanceof ModelContentMeasureExceeded) return { values, bytes, exceeded: true }
+ return undefined
+ }
+}
+
/** Returns the registry-revision-cached matcher used for all model-visible projection. */
export function getResolvedSecretModelMatcher(
registry: ResolvedSecretTraceRegistry | undefined
@@ -396,7 +503,7 @@ export function getResolvedSecretModelMatcher(
export function projectResolvedSecretModelContent(
value: unknown,
registry: ResolvedSecretTraceRegistry | undefined,
- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
+ maxBytes = MAX_MODEL_CONTENT_BYTES,
options: ResolvedSecretContentProjectionOptions = {}
): ResolvedSecretContentProjection {
const snapshot = getResolvedSecretModelMatcher(registry)
@@ -419,7 +526,7 @@ export function projectResolvedSecretModelContent(
export function projectResolvedSecretModelJsonContent(
value: unknown,
registry: ResolvedSecretTraceRegistry | undefined,
- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
+ maxBytes = MAX_MODEL_CONTENT_BYTES,
options: ResolvedSecretContentProjectionOptions = {}
): ResolvedSecretContentProjection {
const snapshot = getResolvedSecretModelMatcher(registry)
@@ -455,7 +562,7 @@ export function projectResolvedSecretModelJsonContent(
export function projectResolvedSecretDiagnosticContent(
value: unknown,
registry: ResolvedSecretTraceRegistry | undefined,
- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
+ maxBytes = MAX_MODEL_CONTENT_BYTES
): ResolvedSecretContentProjection {
return projectResolvedSecretModelContent(value, registry, maxBytes, {
sanitizeInternalIdentifiers: true,
@@ -509,7 +616,7 @@ export function isResolvedSecretModelContentUnchanged(
if (!snapshot.complete) return false
if (!snapshot.matcher) return true
- const projection = projectContent(value, snapshot.matcher, MAX_INLINE_MATERIALIZATION_BYTES, {
+ const projection = projectContent(value, snapshot.matcher, MAX_MODEL_CONTENT_BYTES, {
projectPrimitiveLiterals: true,
rejectResolvedSecretLiterals: true,
})
@@ -523,7 +630,7 @@ export function isResolvedSecretModelContentUnchanged(
export function projectResolvedSecretModelJsonStrings(
values: readonly (string | undefined)[],
registry: ResolvedSecretTraceRegistry | undefined,
- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
+ maxBytes = MAX_MODEL_CONTENT_BYTES
): ResolvedSecretContentProjection {
const snapshot = getResolvedSecretModelMatcher(registry)
if (!snapshot.complete) return { safe: false }
diff --git a/apps/sim/hooks/mcp/use-mcp-tools.ts b/apps/sim/hooks/mcp/use-mcp-tools.ts
index 9ff721a2205..afcb68da5f9 100644
--- a/apps/sim/hooks/mcp/use-mcp-tools.ts
+++ b/apps/sim/hooks/mcp/use-mcp-tools.ts
@@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { McpIcon } from '@/components/icons'
import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons'
+import { getManagedMcpConnectorBgColor } from '@/lib/credential-groups/managed-mcp-connectors'
import { createMcpToolId } from '@/lib/mcp/shared'
import type { McpToolSchema } from '@/lib/mcp/types'
import { useMcpToolsQuery } from '@/hooks/queries/mcp'
@@ -54,7 +55,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
serverName: tool.serverName,
type: 'mcp' as const,
inputSchema: tool.inputSchema,
- bgColor: '#6366F1',
+ bgColor: getManagedMcpConnectorBgColor(tool.managedConnectorId) ?? '#6366F1',
icon: tool.managedConnectorId ? getManagedMcpConnectorIcon(tool.managedConnectorId) : McpIcon,
}))
}, [mcpToolsData])
diff --git a/apps/sim/hooks/queries/dashboards.ts b/apps/sim/hooks/queries/dashboards.ts
new file mode 100644
index 00000000000..ce26b57474f
--- /dev/null
+++ b/apps/sim/hooks/queries/dashboards.ts
@@ -0,0 +1,20 @@
+import { useQuery } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import { readWorkspaceDashboardContract } from '@/lib/api/contracts/dashboards'
+
+export const DASHBOARD_STALE_TIME = 30_000
+export const dashboardKeys = {
+ all: ['dashboards'] as const,
+ workspace: (workspaceId: string) => [...dashboardKeys.all, workspaceId] as const,
+}
+
+/** The workspace's single dashboard; `dashboard` and `content` are null until the first save. */
+export function useWorkspaceDashboard(workspaceId: string, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: dashboardKeys.workspace(workspaceId),
+ queryFn: ({ signal }) =>
+ requestJson(readWorkspaceDashboardContract, { params: { id: workspaceId }, signal }),
+ enabled: Boolean(workspaceId) && (options?.enabled ?? true),
+ staleTime: DASHBOARD_STALE_TIME,
+ })
+}
diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts
index ce1d2406d97..e6bfc7d9c97 100644
--- a/apps/sim/hooks/queries/kb/connectors.test.ts
+++ b/apps/sim/hooks/queries/kb/connectors.test.ts
@@ -2,11 +2,13 @@ import {
apiClientRequestMock,
apiClientRequestMockFns,
} from '@sim/testing/mocks/api-client-request.mock'
+import { emcnMock } from '@sim/testing/mocks/emcn.mock'
import { reactQueryMock, reactQueryMockFns } from '@sim/testing/mocks/react-query.mock'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
vi.mock('react', () => ({ useEffect: vi.fn() }))
+vi.mock('@sim/emcn', () => emcnMock)
vi.mock('@tanstack/react-query', () => reactQueryMock)
diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts
index 661f886ba5b..9ea8c72b571 100644
--- a/apps/sim/hooks/queries/organization-accounts.ts
+++ b/apps/sim/hooks/queries/organization-accounts.ts
@@ -1,5 +1,7 @@
'use client'
+import { useEffect, useRef } from 'react'
+import { generateId } from '@sim/utils/id'
import {
isServer,
useInfiniteQuery,
@@ -10,6 +12,7 @@ import {
import { useRouter } from 'next/navigation'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
+import type { DesktopSourceRequest } from '@/lib/api/contracts/desktop-source-connect'
import {
type AddOrganizationAccountMcpProviderBody,
addOrganizationAccountMcpProviderContract,
@@ -39,6 +42,9 @@ import {
updateOrganizationAccountsContract,
updateOrganizationAccountWorkspaceAccessContract,
} from '@/lib/api/contracts/organization-accounts'
+import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup'
+import { isDesktopApp } from '@/lib/desktop'
+import { connectDesktopSource } from '@/lib/desktop/source-connect'
import { personalCredentialKeys } from '@/hooks/queries/personal-credentials'
import { mcpKeys } from '@/hooks/queries/utils/mcp-keys'
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
@@ -48,13 +54,61 @@ import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys'
export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000
-export function useReconnectPersonalOrganizationAccount() {
+function useAccountConnectionMutation(
+ requestFor: (
+ variables: Variables
+ ) => Extract
+) {
+ const client = useQueryClient()
+ const pending = useRef(null)
+ useEffect(() => () => pending.current?.abort(), [])
return useMutation({
- mutationFn: (credentialId: string) =>
- requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }),
+ mutationKey: organizationAccountsKeys.connection(),
+ mutationFn: async (variables: Variables) => {
+ if (client.isMutating({ mutationKey: organizationAccountsKeys.connection() }) > 1) {
+ throw new Error('Finish or cancel your current account connection before starting another.')
+ }
+ pending.current?.abort()
+ const controller = new AbortController()
+ pending.current = controller
+ const completionId = generateId()
+ const input = requestFor(variables)
+ const request =
+ input.kind === 'organization-account'
+ ? { ...input, body: { ...input.body, oauthCompletionId: completionId } }
+ : { ...input, completionId }
+ if (isDesktopApp()) {
+ await connectDesktopSource(request, controller.signal)
+ } else {
+ await connectCredentialGroupInPopup(
+ completionId,
+ (signal) =>
+ request.kind === 'organization-account'
+ ? requestJson(startOrganizationAccountConnectionContract, {
+ params: { id: request.organizationId },
+ body: request.body,
+ signal,
+ })
+ : requestJson(reconnectPersonalOrganizationAccountContract, {
+ params: { credentialId: request.credentialId },
+ query: { oauthCompletionId: completionId },
+ signal,
+ }),
+ controller.signal
+ )
+ }
+ },
+ onSettled: () => refreshAccounts(client),
})
}
+export function useReconnectPersonalOrganizationAccount() {
+ return useAccountConnectionMutation((credentialId: string) => ({
+ kind: 'reconnect-account',
+ credentialId,
+ }))
+}
+
/** Disconnects an owned grant; indexing and source setup do not gate this operation. */
export function useDisconnectPersonalOrganizationAccount(organizationId: string) {
const queryClient = useQueryClient()
@@ -81,6 +135,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string)
export const organizationAccountsKeys = {
all: ['organization-accounts'] as const,
+ connection: () => [...organizationAccountsKeys.all, 'connection'] as const,
workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const,
workspace: (workspaceId?: string) =>
[...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const,
@@ -194,17 +249,26 @@ export function useUpdateOrganizationAccounts() {
})
}
+async function refreshAccounts(client: ReturnType) {
+ await Promise.all([
+ client.invalidateQueries({ queryKey: organizationAccountsKeys.all }),
+ client.invalidateQueries({ queryKey: personalCredentialKeys.lists() }),
+ client.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }),
+ invalidateSelectorQueries(client),
+ ])
+}
+
export function useConnectOrganizationAccount() {
- return useMutation({
- mutationFn: ({
+ return useAccountConnectionMutation(
+ ({
organizationId,
...body
- }: { organizationId: string } & StartOrganizationAccountConnectionBody) =>
- requestJson(startOrganizationAccountConnectionContract, {
- params: { id: organizationId },
- body,
- }),
- })
+ }: { organizationId: string } & StartOrganizationAccountConnectionBody) => ({
+ kind: 'organization-account',
+ organizationId,
+ body,
+ })
+ )
}
export function useWorkspaceOrganizationAccounts(workspaceId?: string, enabled = true) {
diff --git a/apps/sim/hooks/queries/personal-search-integrations.ts b/apps/sim/hooks/queries/personal-search-integrations.ts
index 85eeb11f040..1f718b464b6 100644
--- a/apps/sim/hooks/queries/personal-search-integrations.ts
+++ b/apps/sim/hooks/queries/personal-search-integrations.ts
@@ -8,6 +8,8 @@ import {
listPersonalSearchIntegrationsContract,
type PersonalSearchIntegrationsQuery,
} from '@/lib/api/contracts/knowledge/personal-integrations'
+import { isDesktopApp } from '@/lib/desktop'
+import { connectDesktopSource } from '@/lib/desktop/source-connect'
import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
@@ -35,8 +37,16 @@ export function usePersonalSearchIntegrations(
export function useConnectPersonalSearchIntegration() {
const client = useQueryClient()
return useMutation({
- mutationFn: async (body: ConnectPersonalSearchIntegrationBody) =>
- (await requestJson(connectPersonalSearchIntegrationContract, { body })).data,
+ mutationFn: async ({
+ signal,
+ ...body
+ }: ConnectPersonalSearchIntegrationBody & { signal?: AbortSignal }) => {
+ if (isDesktopApp()) {
+ await connectDesktopSource({ kind: 'personal-search', body }, signal)
+ return null
+ }
+ return (await requestJson(connectPersonalSearchIntegrationContract, { body, signal })).data
+ },
onSettled: (_data, _error, body) =>
Promise.all([
client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }),
diff --git a/apps/sim/hooks/queries/slack-search.ts b/apps/sim/hooks/queries/slack-search.ts
index 98eb62e41d8..39a02552e3c 100644
--- a/apps/sim/hooks/queries/slack-search.ts
+++ b/apps/sim/hooks/queries/slack-search.ts
@@ -13,6 +13,8 @@ import {
type StartSlackSearchOAuthBody,
startSlackSearchOAuthContract,
} from '@/lib/api/contracts/knowledge/slack'
+import { isDesktopApp } from '@/lib/desktop'
+import { connectDesktopSource } from '@/lib/desktop/source-connect'
import {
SLACK_SEARCH_DEFAULT_DESCRIPTION,
SLACK_SEARCH_DEFAULT_NAME,
@@ -36,9 +38,28 @@ export function useSlackSearchManifest(organizationId: string, name = SLACK_SEAR
}
export function useStartSlackSearchOAuth() {
+ const client = useQueryClient()
return useMutation({
- mutationFn: (body: StartSlackSearchOAuthBody) =>
- requestJson(startSlackSearchOAuthContract, { body }),
+ mutationFn: async ({
+ signal,
+ ...body
+ }: StartSlackSearchOAuthBody & { signal?: AbortSignal }) => {
+ if (isDesktopApp()) {
+ await connectDesktopSource({ kind: 'slack-search', body }, signal)
+ return null
+ }
+ return requestJson(startSlackSearchOAuthContract, { body, signal })
+ },
+ onSettled: (_data, _error, input) =>
+ Promise.all([
+ client.invalidateQueries({ queryKey: slackSearchKeys.list(input.organizationId) }),
+ client.invalidateQueries({
+ queryKey: slackSearchKeys.organizationManifests(input.organizationId),
+ }),
+ client.invalidateQueries({
+ queryKey: organizationAccountsKeys.detail(input.organizationId),
+ }),
+ ]),
})
}
diff --git a/apps/sim/hooks/queries/table-analytics.test.tsx b/apps/sim/hooks/queries/table-analytics.test.tsx
new file mode 100644
index 00000000000..fbf7aaf3150
--- /dev/null
+++ b/apps/sim/hooks/queries/table-analytics.test.tsx
@@ -0,0 +1,132 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import {
+ apiClientRequestMock,
+ apiClientRequestMockFns,
+} from '@sim/testing/mocks/api-client-request.mock'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ QueryTableAnalyticsBody,
+ QueryTableAnalyticsResponse,
+} from '@/lib/api/contracts/table-analytics'
+import { useTableAnalytics } from '@/hooks/queries/table-analytics'
+
+vi.mock('@/lib/api/client/request', () => apiClientRequestMock)
+const mocks = { request: apiClientRequestMockFns.mockRequestJson }
+
+const BODY: QueryTableAnalyticsBody = {
+ workspaceId: 'workspace-1',
+ query: {
+ from: '2026-09-17T00:00:00Z',
+ to: '2026-09-24T00:00:00Z',
+ aggregate: { total: { op: 'count' } },
+ },
+}
+const DATA: QueryTableAnalyticsResponse = {
+ rows: [{ total: 38 }],
+ columns: ['total'],
+ columnLabels: { total: 'total' },
+ bucket: null,
+ truncated: false,
+}
+interface ProbeProps {
+ tableId: string
+ body: QueryTableAnalyticsBody
+}
+
+describe('dashboard range transitions', () => {
+ let root: Root
+ let client: QueryClient
+ let result: ReturnType
+
+ function Probe(props: ProbeProps) {
+ result = useTableAnalytics(props)
+ return null
+ }
+ async function render(body = BODY, tableId = 'table-1') {
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ }
+ beforeEach(() => {
+ mocks.request.mockReset()
+ vi.useFakeTimers()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.request.mockResolvedValue(DATA)
+ client = new QueryClient()
+ root = createRoot(document.createElement('div'))
+ })
+ afterEach(() => {
+ act(() => root.unmount())
+ client.clear()
+ vi.useRealTimers()
+ })
+
+ it('keeps results while the new range loads, then replaces them', async () => {
+ await render()
+ let finish!: (value: QueryTableAnalyticsResponse) => void
+ mocks.request.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ await render({ ...BODY, query: { ...BODY.query, from: '2026-09-20T00:00:00Z' } })
+ expect(result.isPlaceholderData).toBe(true)
+ expect(result.isFetching).toBe(true)
+ expect(result.data).toEqual({
+ ...DATA,
+ queryRange: { from: BODY.query.from, to: BODY.query.to },
+ })
+ await act(async () => {
+ finish({ ...DATA, rows: [{ total: 12 }] })
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(result.isPlaceholderData).toBe(false)
+ expect(result.data?.rows).toEqual([{ total: 12 }])
+ expect(result.data?.queryRange).toEqual({ from: '2026-09-20T00:00:00Z', to: BODY.query.to })
+ })
+
+ it.each(['table', 'workspace', 'selection'] as const)(
+ 'does not retain results across a different %s',
+ async (scope) => {
+ await render()
+ mocks.request.mockReturnValue(new Promise(() => {}))
+ await render(
+ scope === 'workspace'
+ ? { ...BODY, workspaceId: 'workspace-2' }
+ : scope === 'selection'
+ ? {
+ ...BODY,
+ query: {
+ ...BODY.query,
+ aggregate: { total: { op: 'countDistinct', field: 'alarm' } },
+ },
+ }
+ : BODY,
+ scope === 'table' ? 'table-2' : 'table-1'
+ )
+ expect(result.isPending).toBe(true)
+ expect(result.data).toBeUndefined()
+ }
+ )
+
+ it('shows an error instead of keeping stale values after a failed range query', async () => {
+ await render()
+ mocks.request.mockRejectedValue(new Error('Database unavailable'))
+ await render({ ...BODY, query: { ...BODY.query, from: '2026-09-20T00:00:00Z' } })
+ expect(result.isError).toBe(true)
+ expect(result.error?.message).toBe('Database unavailable')
+ expect(result.data).toBeUndefined()
+ })
+})
diff --git a/apps/sim/hooks/queries/table-analytics.ts b/apps/sim/hooks/queries/table-analytics.ts
new file mode 100644
index 00000000000..9ed2648a3cc
--- /dev/null
+++ b/apps/sim/hooks/queries/table-analytics.ts
@@ -0,0 +1,48 @@
+'use client'
+
+import { omit } from '@sim/utils/object'
+import { hashKey, keepPreviousData, useQuery } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ type QueryTableAnalyticsBody,
+ queryTableAnalyticsContract,
+} from '@/lib/api/contracts/table-analytics'
+
+export const TABLE_ANALYTICS_STALE_TIME = 60_000
+export const tableAnalyticsKeys = {
+ all: ['table-analytics'] as const,
+ queries: () => [...tableAnalyticsKeys.all, 'query'] as const,
+ query: (tableId: string, body: QueryTableAnalyticsBody) =>
+ [...tableAnalyticsKeys.queries(), tableId, body] as const,
+}
+
+interface UseTableAnalyticsProps {
+ tableId: string
+ body: QueryTableAnalyticsBody
+}
+export function useTableAnalytics({ tableId, body }: UseTableAnalyticsProps) {
+ return useQuery({
+ queryKey: tableAnalyticsKeys.query(tableId, body),
+ queryFn: async ({ signal }) => {
+ const result = await requestJson(queryTableAnalyticsContract, {
+ params: { tableId },
+ body,
+ signal,
+ })
+ return { ...result, queryRange: { from: body.query.from, to: body.query.to } }
+ },
+ staleTime: TABLE_ANALYTICS_STALE_TIME,
+ placeholderData: (previousData, previousQuery) => {
+ const previousKey = previousQuery?.queryKey
+ if (!previousKey) return undefined
+ const [, , previousTableId, previousBody] = previousKey
+ const sameSelection =
+ previousTableId === tableId &&
+ previousBody.workspaceId === body.workspaceId &&
+ hashKey([omit(previousBody.query, ['from', 'to'])]) ===
+ hashKey([omit(body.query, ['from', 'to'])])
+ return sameSelection ? keepPreviousData(previousData) : undefined
+ },
+ retry: false,
+ })
+}
diff --git a/apps/sim/hooks/use-github-installation-setup.ts b/apps/sim/hooks/use-github-installation-setup.ts
index 3ace14a876a..a44e3baa625 100644
--- a/apps/sim/hooks/use-github-installation-setup.ts
+++ b/apps/sim/hooks/use-github-installation-setup.ts
@@ -3,12 +3,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { StartGitHubSearchSetupBody } from '@/lib/api/contracts/knowledge/github-setup'
import {
credentialGroupOAuthCompletionChannel,
isCredentialGroupOAuthFailure,
} from '@/lib/credential-groups/oauth-completion'
+import { isDesktopApp } from '@/lib/desktop'
+import { connectDesktopSource } from '@/lib/desktop/source-connect'
import { resolveGitHubSetupUrl } from '@/lib/knowledge/github-setup-navigation'
import {
isGitHubSetupTerminalError,
@@ -16,7 +18,7 @@ import {
useGitHubSearchSetup,
useStartGitHubSearchSetup,
} from '@/hooks/queries/github-search-setup'
-import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials'
+import { fetchOAuthCredentials, oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials'
import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts'
interface GitHubInstallationSetupProps {
@@ -30,6 +32,38 @@ export function useGitHubInstallationSetup({
onConnected,
}: GitHubInstallationSetupProps) {
const active = useRef<{ setupId: string; tab: Window } | null>(null)
+ const nativeAbort = useRef(null)
+ const client = useQueryClient()
+ const nativeConnection = useMutation({
+ mutationFn: async ({
+ body,
+ signal,
+ }: {
+ body: StartGitHubSearchSetupBody
+ signal: AbortSignal
+ }) => {
+ const result = await connectDesktopSource({ kind: 'github-setup', body }, signal)
+ const credentials = await fetchOAuthCredentials(
+ { providerId: 'github-repositories', organizationId: body.organizationId },
+ signal
+ )
+ signal.throwIfAborted()
+ if (
+ !result.credentialId ||
+ !credentials.some((credential) => credential.id === result.credentialId)
+ )
+ throw new Error('GitHub is not available for this source. Try connecting again.')
+ await Promise.all([
+ client.invalidateQueries({ queryKey: oauthCredentialKeys.lists() }),
+ client.invalidateQueries({
+ queryKey: organizationAccountsKeys.detail(body.organizationId),
+ }),
+ ])
+ signal.throwIfAborted()
+ return result.credentialId
+ },
+ })
+ const { mutateAsync: startNative, isPending: nativePending } = nativeConnection
const checking = useRef(null)
const callback = useRef(onConnected)
const [setupId, setSetupId] = useState()
@@ -40,7 +74,6 @@ export function useGitHubInstallationSetup({
setSetupId(undefined)
setError(null)
}
- const client = useQueryClient()
const { mutateAsync: start, isPending: isStarting } = useStartGitHubSearchSetup()
const { mutateAsync: cancelSetup } = useCancelGitHubSearchSetup()
const scope = organizationId && setupId ? { organizationId, setupId } : undefined
@@ -53,6 +86,7 @@ export function useGitHubInstallationSetup({
useEffect(() => {
return () => {
+ nativeAbort.current?.abort()
const attempt = active.current
active.current = null
attempt?.tab.close()
@@ -175,6 +209,25 @@ export function useGitHubInstallationSetup({
const connect = useCallback(
async (intent?: StartGitHubSearchSetupBody['intent']) => {
if (!organizationId) return
+ if (isDesktopApp()) {
+ if (nativeAbort.current) return
+ const controller = new AbortController()
+ nativeAbort.current = controller
+ setError(null)
+ try {
+ const credentialId = await startNative({
+ body: { organizationId, setupId: generateId(), ...(intent ? { intent } : {}) },
+ signal: controller.signal,
+ })
+ callback.current(credentialId)
+ } catch (failure) {
+ if (!controller.signal.aborted)
+ setError(getErrorMessage(failure, 'Could not connect GitHub'))
+ } finally {
+ if (nativeAbort.current === controller) nativeAbort.current = null
+ }
+ return
+ }
if (active.current) {
active.current.tab.focus()
return
@@ -203,10 +256,12 @@ export function useGitHubInstallationSetup({
void cancelSetup({ organizationId, setupId: id }).catch(() => undefined)
}
},
- [organizationId, start, cancelSetup]
+ [organizationId, start, cancelSetup, startNative]
)
const cancel = useCallback(() => {
+ nativeAbort.current?.abort()
+ nativeAbort.current = null
const attempt = active.current
if (!attempt || !organizationId) return
active.current = null
@@ -221,7 +276,7 @@ export function useGitHubInstallationSetup({
cancel,
checkConnection,
isChecking: isStarting,
- pending: isStarting || Boolean(setupId),
+ pending: nativePending || isStarting || Boolean(setupId),
error,
}
}
diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts
index 61c6c626a93..8509d0d33be 100644
--- a/apps/sim/hooks/use-oauth-return.ts
+++ b/apps/sim/hooks/use-oauth-return.ts
@@ -510,6 +510,7 @@ export function useDesktopOAuthConnectListener() {
if (!bridge?.onOAuthConnectComplete) return
return bridge.onOAuthConnectComplete((result) => {
+ if (result.sourceRequestId) return
void queryClient.invalidateQueries({
queryKey: oauthConnectionsKeys.connections(),
})
diff --git a/apps/sim/hooks/use-search-integration-connection.ts b/apps/sim/hooks/use-search-integration-connection.ts
index 3f0cd7873cd..ed7ba243072 100644
--- a/apps/sim/hooks/use-search-integration-connection.ts
+++ b/apps/sim/hooks/use-search-integration-connection.ts
@@ -9,6 +9,7 @@ import {
credentialGroupOAuthCompletionChannel,
isCredentialGroupOAuthFailure,
} from '@/lib/credential-groups/oauth-completion'
+import { isDesktopApp } from '@/lib/desktop'
import {
readSearchConnectionAttempt,
SEARCH_CONNECTION_ATTEMPT_EVENT,
@@ -51,6 +52,8 @@ export function useSearchIntegrationConnection({
const [localError, setLocalError] = useState(null)
const popup = useRef(null)
const starting = useRef(false)
+ const nativeAbort = useRef(null)
+ useEffect(() => () => nativeAbort.current?.abort(), [])
const callback = useRef(onConnected)
useEffect(() => {
callback.current = onConnected
@@ -156,19 +159,26 @@ export function useSearchIntegrationConnection({
popup.current.focus()
return
}
- const tab = window.open('about:blank', '_blank', 'width=600,height=700')
- if (!tab) {
+ const desktop = isDesktopApp()
+ if (desktop && pending) return
+ const tab = desktop ? null : window.open('about:blank', '_blank', 'width=600,height=700')
+ if (!desktop && !tab) {
setLocalError('Allow pop-ups for this site to connect your account.')
return
}
- tab.opener = null
+ if (tab) tab.opener = null
popup.current = tab
starting.current = true
+ const controller = new AbortController()
+ nativeAbort.current = controller
setLocalError(null)
let next: SearchConnectionAttempt | undefined
try {
- const fresh = await refetch()
- if (!fresh.isSuccess) throw fresh.error
+ if (!desktop) {
+ const fresh = await refetch()
+ if (!fresh.isSuccess) throw fresh.error
+ }
+ controller.signal.throwIfAborted()
next = {
completionId: generateId(),
requestedAt: Date.now(),
@@ -182,7 +192,9 @@ export function useSearchIntegrationConnection({
target: connectorId ? { ...target, connectorId } : target,
sourceConfig,
oauthCompletionId: next.completionId,
+ signal: controller.signal,
})
+ if (!result || !tab) return true
const url = new URL(result.url)
if (
url.protocol !== 'https:' &&
@@ -193,10 +205,12 @@ export function useSearchIntegrationConnection({
tab.location.href = url.href
return true
} catch (error) {
- tab.close()
+ tab?.close()
const message = getErrorMessage(error, 'Could not start the connection')
- if (next) writeSearchConnectionAttempt(key, { ...next, status: 'failed', error: message })
- setLocalError(message)
+ const current = readSearchConnectionAttempt(key)
+ if (next && current?.completionId === next.completionId && current.status === 'pending')
+ writeSearchConnectionAttempt(key, { ...current, status: 'failed', error: message })
+ if (!controller.signal.aborted) setLocalError(message)
return false
} finally {
starting.current = false
@@ -205,6 +219,7 @@ export function useSearchIntegrationConnection({
[isPending, connected, pending, refetch, mutateAsync, organizationId, target, connectorId, key]
)
const cancel = useCallback(() => {
+ nativeAbort.current?.abort()
popup.current?.close()
if (attempt?.status === 'pending')
writeSearchConnectionAttempt(key, {
diff --git a/apps/sim/lib/analytics/freebuff.server.ts b/apps/sim/lib/analytics/freebuff.server.ts
new file mode 100644
index 00000000000..3db98deb40a
--- /dev/null
+++ b/apps/sim/lib/analytics/freebuff.server.ts
@@ -0,0 +1,67 @@
+import { createLogger } from '@sim/logger'
+import { sleep } from '@sim/utils/helpers'
+import { backoffWithJitter } from '@sim/utils/retry'
+import type { FreebuffConversionEvent } from '@/lib/analytics/freebuff'
+import { env } from '@/lib/core/config/env'
+
+const logger = createLogger('FreebuffConversions')
+
+const FREEBUFF_CONVERSIONS_URL = 'https://freebuff.com/api/advertisers/conversions'
+const MAX_ATTEMPTS = 4
+const REQUEST_TIMEOUT_MS = 10_000
+
+/** Mirrors the tag's own click-id check, so a tampered cookie is never forwarded. */
+const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/
+
+interface FreebuffConversion {
+ clickId: string
+ eventType: FreebuffConversionEvent
+ /** Idempotency key, shared with the tag call for the same conversion. */
+ eventId: string
+ occurredAt: Date
+}
+
+/**
+ * Server-to-server conversion postback. Network failures and 5xx responses are
+ * retried with the same `eventId` and `occurredAt`; every 4xx is terminal. A
+ * `deduped` answer means the tag already reported it and is a success. Never
+ * throws, so a caller can fire and forget.
+ */
+export async function reportFreebuffConversion(conversion: FreebuffConversion): Promise {
+ const apiKey = env.FREEBUFF_API_KEY
+ if (!apiKey || !CLICK_ID_SHAPE.test(conversion.clickId)) return
+
+ const body = JSON.stringify({
+ clickId: conversion.clickId,
+ eventType: conversion.eventType,
+ eventId: conversion.eventId,
+ occurredAt: conversion.occurredAt.toISOString(),
+ })
+ const context = { eventType: conversion.eventType, eventId: conversion.eventId }
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ let status: number | undefined
+ try {
+ const response = await fetch(FREEBUFF_CONVERSIONS_URL, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
+ body,
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ status = response.status
+ const result = await response.text().catch(() => '')
+ if (response.ok) {
+ logger.info('Freebuff conversion recorded', { ...context, result })
+ return
+ }
+ if (status < 500) {
+ logger.warn('Freebuff conversion rejected', { ...context, status, result })
+ return
+ }
+ } catch (error) {
+ logger.warn('Freebuff conversion request failed', { ...context, attempt, error })
+ }
+ if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null))
+ else logger.error('Freebuff conversion postback gave up', { ...context, status })
+ }
+}
diff --git a/apps/sim/lib/analytics/freebuff.ts b/apps/sim/lib/analytics/freebuff.ts
new file mode 100644
index 00000000000..6f994523d14
--- /dev/null
+++ b/apps/sim/lib/analytics/freebuff.ts
@@ -0,0 +1,49 @@
+/**
+ * Freebuff Ads conversion tracking. A Freebuff ad click lands with a signed
+ * `?bfcid=` click id; the hosted tag stores it in a first-party `bfcid` cookie,
+ * and each conversion is reported twice with the same `eventId` — once by the
+ * tag and once by the server postback — so Freebuff dedupes them into one.
+ *
+ * @see https://freebuff.com/docs/advertisers/conversions
+ */
+
+export const FREEBUFF_TAG_SRC = 'https://freebuff.com/freebuff-tag.js' as const
+
+/** First-party cookie the tag writes the captured click id to. */
+export const FREEBUFF_CLICK_ID_COOKIE = 'bfcid' as const
+
+export type FreebuffConversionEvent = 'signup_completed'
+
+type FreebuffCommand = (
+ command: 'conversion',
+ eventType: FreebuffConversionEvent,
+ options?: { eventId?: string }
+) => void
+
+declare global {
+ interface Window {
+ freebuff?: FreebuffCommand & { q?: unknown[][] }
+ }
+}
+
+/** Queues commands until the async tag loads and replays them. */
+export function installFreebuffStub(): void {
+ if (window.freebuff) return
+ const queue: unknown[][] = []
+ window.freebuff = Object.assign((...args: unknown[]) => void queue.push(args), { q: queue })
+}
+
+/** Deletes the tag's click-id cookie, which it writes host-only on `Path=/`. */
+export function clearFreebuffClickId(): void {
+ if (!document.cookie.includes(`${FREEBUFF_CLICK_ID_COOKIE}=`)) return
+ document.cookie = `${FREEBUFF_CLICK_ID_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`
+}
+
+/**
+ * Reports a conversion from the page. Call only after the caller has verified
+ * marketing consent; the tag is a no-op for visitors who did not arrive from an
+ * ad. `eventId` must match the one the server postback sends.
+ */
+export function trackFreebuffConversion(event: FreebuffConversionEvent, eventId: string): void {
+ window.freebuff?.('conversion', event, { eventId })
+}
diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts
index d9fcd2ff2a5..5989ca03b4e 100644
--- a/apps/sim/lib/api/contracts/copilot.ts
+++ b/apps/sim/lib/api/contracts/copilot.ts
@@ -3,6 +3,7 @@ import { persistedContentBlockSchema } from '@/lib/api/contracts/copilot-message
import { workspaceSearchFiltersSchema } from '@/lib/api/contracts/knowledge/search'
import { mothershipResourceSchema } from '@/lib/api/contracts/mothership-resources'
import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
+import { usageUpgradePayloadSchema } from '@/lib/api/contracts/subscription'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
@@ -200,6 +201,8 @@ export const copilotChatStreamQuerySchema = z.object({
.string()
.optional()
.transform((value) => value === 'true'),
+ /** `log` once the reader was re-synced from the worker log: its cursor is a log position. */
+ source: z.enum(['ring', 'log']).optional(),
})
export const copilotChatStopBodySchema = z.object({
@@ -270,6 +273,38 @@ export const validateCopilotApiKeyResponseSchema = z.object({
})
export type ValidateCopilotApiKeyResponse = z.output
+export const COPILOT_USAGE_LIMIT_EXCEEDED_CODE = 'USAGE_LIMIT_EXCEEDED'
+export const COPILOT_BILLING_BLOCKED_CODE = 'BILLING_BLOCKED'
+
+/**
+ * A continuation refused because the run's original payer is over its usage limit. A worker
+ * polling continuation validation mid-run writes `usageUpgrade` as a `` tag into
+ * its log and pauses the run for the limit.
+ */
+export const validateCopilotApiKeyUsageExceededSchema = z.object({
+ code: z.literal(COPILOT_USAGE_LIMIT_EXCEEDED_CODE),
+ error: z.string(),
+ usageUpgrade: usageUpgradePayloadSchema,
+})
+export type ValidateCopilotApiKeyUsageExceeded = z.output<
+ typeof validateCopilotApiKeyUsageExceededSchema
+>
+
+/** A continuation refused because the actor or payer account is blocked (payment, dispute). */
+export const validateCopilotApiKeyBillingBlockedSchema = z.object({
+ code: z.literal(COPILOT_BILLING_BLOCKED_CODE),
+ error: z.string(),
+})
+export type ValidateCopilotApiKeyBillingBlocked = z.output<
+ typeof validateCopilotApiKeyBillingBlockedSchema
+>
+
+/** A continuation 402 carries one of these bodies; a new turn's 402 is empty. */
+export const validateCopilotApiKeyRefusalSchema = z.union([
+ validateCopilotApiKeyUsageExceededSchema,
+ validateCopilotApiKeyBillingBlockedSchema,
+])
+
export const listCopilotApiKeysContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/api-keys',
@@ -392,7 +427,15 @@ export const validateCopilotApiKeyContract = defineRouteContract({
path: '/api/copilot/api-keys/validate',
headers: validateCopilotApiKeyHeadersSchema,
body: validateCopilotApiKeyBodySchema,
- response: { mode: 'json', schema: validateCopilotApiKeyResponseSchema },
+ response: {
+ mode: 'json',
+ schema: validateCopilotApiKeyResponseSchema,
+ status: [200, 402],
+ statusSchemas: {
+ 200: validateCopilotApiKeyResponseSchema,
+ 402: validateCopilotApiKeyRefusalSchema.optional(),
+ },
+ },
error: validateCopilotApiKeyErrorSchema,
})
diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts
index 91349131b7f..ef4373f5169 100644
--- a/apps/sim/lib/api/contracts/credential-groups.ts
+++ b/apps/sim/lib/api/contracts/credential-groups.ts
@@ -372,6 +372,8 @@ export const createCredentialGroupMcpConnectorBodySchema = z.discriminatedUnion(
z.object({ connectorId: z.literal('notion') }).strict(),
z.object({ connectorId: z.literal('coda') }).strict(),
z.object({ connectorId: z.literal('hubspot') }).strict(),
+ z.object({ connectorId: z.literal('lucid') }).strict(),
+ z.object({ connectorId: z.literal('zoom') }).strict(),
z
.object({
connectorId: z.literal('databricks'),
diff --git a/apps/sim/lib/api/contracts/dashboards.ts b/apps/sim/lib/api/contracts/dashboards.ts
new file mode 100644
index 00000000000..e23f0367aea
--- /dev/null
+++ b/apps/sim/lib/api/contracts/dashboards.ts
@@ -0,0 +1,32 @@
+import { z } from 'zod'
+import { defineRouteContract } from '@/lib/api/contracts'
+import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
+
+const workspaceParams = z.object({ id: workspaceIdSchema })
+export const dashboardContentSchema = z
+ .string()
+ .min(1)
+ .max(128 * 1024)
+export const dashboardRevisionSchema = z.string().min(1).max(256)
+export const dashboardRecordSchema = z.object({
+ id: z.string(),
+ type: z.literal('dashboard'),
+ name: z.string(),
+ updatedAt: z.string(),
+ revision: dashboardRevisionSchema,
+})
+
+/** A workspace has at most one dashboard, which Sim builds; both fields are null until then. */
+export const readWorkspaceDashboardContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/workspaces/[id]/dashboard',
+ params: workspaceParams,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ dashboard: dashboardRecordSchema.nullable(),
+ content: dashboardContentSchema.nullable(),
+ }),
+ },
+})
+export type DashboardRecord = z.output
diff --git a/apps/sim/lib/api/contracts/desktop-source-connect.ts b/apps/sim/lib/api/contracts/desktop-source-connect.ts
new file mode 100644
index 00000000000..b18e749bbf2
--- /dev/null
+++ b/apps/sim/lib/api/contracts/desktop-source-connect.ts
@@ -0,0 +1,68 @@
+import { z } from 'zod'
+import { startSlackCredentialGroupConfigurationBodySchema } from '@/lib/api/contracts/credential-groups'
+import { connectSimSearchConnectorBodySchema } from '@/lib/api/contracts/knowledge/connectors'
+import { startGitHubSearchSetupBodySchema } from '@/lib/api/contracts/knowledge/github-setup'
+import { connectPersonalSearchIntegrationBodySchema } from '@/lib/api/contracts/knowledge/personal-integrations'
+import { knowledgeConnectorParamsSchema } from '@/lib/api/contracts/knowledge/shared'
+import { startSlackSearchOAuthBodySchema } from '@/lib/api/contracts/knowledge/slack'
+import { startOrganizationAccountConnectionBodySchema } from '@/lib/api/contracts/organization-accounts'
+import { organizationIdSchema, resourceOwnerSchema } from '@/lib/api/contracts/primitives'
+import { defineRouteContract } from '@/lib/api/contracts/types'
+
+export const desktopSourceRequestSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('slack-search'), body: startSlackSearchOAuthBodySchema }),
+ z.object({ kind: z.literal('github-setup'), body: startGitHubSearchSetupBodySchema }),
+ z.object({
+ kind: z.literal('organization-account'),
+ organizationId: organizationIdSchema,
+ body: startOrganizationAccountConnectionBodySchema,
+ }),
+ z.object({
+ kind: z.literal('reconnect-account'),
+ credentialId: z.string().min(1).max(128),
+ completionId: z.string().uuid().optional(),
+ }),
+ z.object({
+ kind: z.literal('personal-search'),
+ body: connectPersonalSearchIntegrationBodySchema,
+ }),
+ z.object({
+ kind: z.literal('member-enrollment'),
+ params: knowledgeConnectorParamsSchema,
+ completionId: z.string().uuid().optional(),
+ }),
+ z.object({
+ kind: z.literal('search-source'),
+ body: connectSimSearchConnectorBodySchema,
+ completionId: z.string().uuid().optional(),
+ }),
+ z.object({
+ kind: z.literal('slack-managed-users'),
+ owner: resourceOwnerSchema,
+ credentialGroupId: z.string().min(1).max(128),
+ body: startSlackCredentialGroupConfigurationBodySchema,
+ }),
+])
+export type DesktopSourceRequest = z.input
+export const desktopSourceRequestIdSchema = z.object({
+ requestId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
+})
+export type DesktopSourceRequestId = z.output
+
+export const createDesktopSourceRequestBodySchema = desktopSourceRequestIdSchema.extend({
+ request: desktopSourceRequestSchema,
+})
+export type CreateDesktopSourceRequestBody = z.input
+
+export const createDesktopSourceRequestContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/desktop/source-connect',
+ body: createDesktopSourceRequestBodySchema,
+ response: { mode: 'json', schema: desktopSourceRequestIdSchema },
+})
+export const consumeDesktopSourceRequestContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/desktop/source-connect/consume',
+ body: desktopSourceRequestIdSchema,
+ response: { mode: 'json', schema: desktopSourceRequestSchema },
+})
diff --git a/apps/sim/lib/api/contracts/knowledge/search-integrations.ts b/apps/sim/lib/api/contracts/knowledge/search-integrations.ts
index 0689b8de524..b0e199baef0 100644
--- a/apps/sim/lib/api/contracts/knowledge/search-integrations.ts
+++ b/apps/sim/lib/api/contracts/knowledge/search-integrations.ts
@@ -11,6 +11,11 @@ export const searchIntegrationApprovalSchema = z.object({
})
export type SearchIntegrationApproval = z.output
+export const searchIntegrationStatusSchema = searchIntegrationApprovalSchema.extend({
+ available: z.boolean().optional(),
+})
+export type SearchIntegrationStatus = z.output
+
export const listSearchIntegrationsQuerySchema = z.object({ organizationId: organizationIdSchema })
export type ListSearchIntegrationsQuery = z.input
export const listSearchIntegrationsContract = defineRouteContract({
@@ -19,7 +24,7 @@ export const listSearchIntegrationsContract = defineRouteContract({
query: listSearchIntegrationsQuerySchema,
response: {
mode: 'json',
- schema: successResponseSchema(z.array(searchIntegrationApprovalSchema).max(100)),
+ schema: successResponseSchema(z.array(searchIntegrationStatusSchema).max(100)),
},
})
diff --git a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts
index 109948291a6..1447831aa29 100644
--- a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts
+++ b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts
@@ -4,8 +4,10 @@ import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog
export const liveSearchProviderSchema = z.enum(LIVE_SEARCH_PROVIDER_IDS)
export type LiveSearchProvider = z.output
-export const NOTION_SEARCH_TERMS_REQUIRED =
- 'Notion requires search terms. Add keywords or a concise question.'
+export const SEARCH_TERMS_REQUIRED = {
+ notion: 'Notion requires search terms. Add keywords or a concise question.',
+ lucid: 'Lucid requires search terms. Add document-title keywords or a literal shape-text query.',
+} as const
/**
* Native queries one call may send to the same provider account. Alternatives run as separate
@@ -24,6 +26,9 @@ const PROVIDER_KIND_SCHEMAS = {
github: z.enum(['issues', 'code', 'repositories', 'commits']),
gitlab: z.enum(['issues', 'code', 'merge_requests', 'wiki']),
hubspot: z.enum(['contacts', 'companies', 'deals', 'tickets']),
+ lucid: z.enum(['lucidchart', 'lucidspark']),
+ google_meet: z.enum(['transcript', 'smart_notes']),
+ zoom: z.enum(['meeting']),
} as const
function hasSearchKinds(
@@ -36,6 +41,9 @@ const nativeSearchKindSchema = z.enum([
...PROVIDER_KIND_SCHEMAS.github.options,
...PROVIDER_KIND_SCHEMAS.gitlab.options,
...PROVIDER_KIND_SCHEMAS.hubspot.options,
+ ...PROVIDER_KIND_SCHEMAS.lucid.options,
+ ...PROVIDER_KIND_SCHEMAS.google_meet.options,
+ ...PROVIDER_KIND_SCHEMAS.zoom.options,
])
/** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */
@@ -53,20 +61,22 @@ export const nativeSearchQuerySchema = z
})
.strict()
.superRefine((input, context) => {
- if (input.kind && hasSearchKinds(input.provider)) {
- const kinds = PROVIDER_KIND_SCHEMAS[input.provider]
- if (!kinds.safeParse(input.kind).success)
+ if (input.kind) {
+ const kinds = hasSearchKinds(input.provider) ? PROVIDER_KIND_SCHEMAS[input.provider] : null
+ if (!kinds?.safeParse(input.kind).success)
context.addIssue({
code: 'custom',
path: ['kind'],
- message: `${input.provider} kind must be one of: ${kinds.options.join(', ')}.`,
+ message: kinds
+ ? `${input.provider} kind must be one of: ${kinds.options.join(', ')}.`
+ : `${input.provider} does not support kind selection.`,
})
}
- if (input.provider === 'notion' && !input.query)
+ if ((input.provider === 'notion' || input.provider === 'lucid') && !input.query)
context.addIssue({
code: 'custom',
path: ['query'],
- message: NOTION_SEARCH_TERMS_REQUIRED,
+ message: SEARCH_TERMS_REQUIRED[input.provider],
})
})
export type NativeSearchQuery = z.output
@@ -106,7 +116,7 @@ export const nativeSearchQueriesSchema = z
earlier.some((previous) => !previous.kind || !query.kind)
)
addIssue(
- 'A GitHub, GitLab, or HubSpot query without a kind already searches every kind; give each query on this account a kind.'
+ 'A GitHub, GitLab, HubSpot, Lucid, Google Meet, or Zoom query without a kind already searches its default kinds; give each query on this account a kind.'
)
else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT)
addIssue(
@@ -141,7 +151,7 @@ export const workspaceSearchFiltersSchema = z.object({
.datetime({ offset: true })
.optional()
.describe(
- 'Live search: inclusive lower date bound. For a specific day or bounded date range, always supply endDate too, including exact-title lookups; startDate alone means an open-ended "since" search. Calendar, Fireflies and Granola use event or meeting start; Gmail/Slack use message time; other sources use modification time. Include the user’s timezone offset.'
+ 'Live search: inclusive lower date bound. For a specific day or bounded date range, always supply endDate too, including exact-title lookups; startDate alone means an open-ended "since" search. Calendar, Google Meet, Zoom, Fireflies and Granola use event or meeting start; Gmail/Slack use message time; other sources use modification time. Include the user’s timezone offset.'
),
endDate: z
.string()
@@ -188,7 +198,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema
nativeQueries: nativeSearchQueriesSchema
.optional()
.describe(
- `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies/HubSpot terms, Granola natural-language questions, Notion keywords or AI questions when available). Blank queries require a date bound or sortBy newest/oldest; Notion always requires search terms. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; one GitHub, GitLab, or HubSpot query without a kind searches GitHub issues (plus code when the query has no date bound or boolean operators, as its status message says), GitLab issues, merge requests, and code, or every HubSpot CRM kind; other collections, and multiple queries on one account, each need a kind, which may repeat. HubSpot kinds are contacts, companies, deals, and tickets; ownership filters are unsupported. Write queries from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.`
+ `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies/HubSpot/Lucid/Zoom terms, bounded local Google Meet text matching, Granola natural-language questions, Notion keywords or AI questions when available). Blank queries require a date bound or sortBy newest/oldest; Notion and Lucid always require search terms. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; one GitHub, GitLab, or HubSpot query without a kind searches GitHub issues (plus code when the query has no date bound or boolean operators, as its status message says), GitLab issues, merge requests, and code, or every HubSpot CRM kind; other collections, and multiple queries on one account, each need a kind, which may repeat. HubSpot kinds are contacts, companies, deals, and tickets; Lucid kinds are lucidchart and lucidspark. Google Meet kinds are transcript and smart_notes (note metadata and Docs link only); it searches bounded recent conference artifacts with 30-day retention. Zoom kind is meeting and searches past occurrences; read for transcripts and separately labeled summaries. Use Drive for saved Meet note bodies and older transcripts; Drive dates mean file modification time. HubSpot, Lucid, Zoom and Meet reject ownership filters. Lucid searches titles with no search continuation; project can scope a literal shape-text query to one known document UUID or Lucid URL. Read for structured diagram evidence. Dates and sorting cover only retrieved candidates, not globally newest/oldest matches. Write queries from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.`
),
query: z
.string()
diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts
index 16b0b2a6a7b..0370e50d74a 100644
--- a/apps/sim/lib/api/contracts/mothership-chats.ts
+++ b/apps/sim/lib/api/contracts/mothership-chats.ts
@@ -203,6 +203,7 @@ export const mothershipChatStreamQuerySchema = z
streamId: z.string().optional(),
after: z.string().optional(),
batch: z.string().optional(),
+ source: z.enum(['ring', 'log']).optional(),
})
.passthrough()
diff --git a/apps/sim/lib/api/contracts/mothership-dashboards.ts b/apps/sim/lib/api/contracts/mothership-dashboards.ts
new file mode 100644
index 00000000000..8db0692ab45
--- /dev/null
+++ b/apps/sim/lib/api/contracts/mothership-dashboards.ts
@@ -0,0 +1,17 @@
+import { z } from 'zod'
+import { dashboardContentSchema, dashboardRevisionSchema } from '@/lib/api/contracts/dashboards'
+
+const scope = z.object({ workspaceId: z.string().min(1).max(100).optional() })
+/** A workspace has one dashboard: read it, then save it (the first save creates it). */
+export const mothershipDashboardsInputSchema = z.discriminatedUnion('action', [
+ scope.extend({ action: z.literal('get') }).strict(),
+ scope
+ .extend({
+ action: z.literal('set'),
+ content: dashboardContentSchema,
+ expectedRevision: dashboardRevisionSchema
+ .optional()
+ .describe('The revision from `dashboards get`; required once the dashboard exists.'),
+ })
+ .strict(),
+])
diff --git a/apps/sim/lib/api/contracts/mothership-management-tools.test.ts b/apps/sim/lib/api/contracts/mothership-management-tools.test.ts
index bfd7b4b3c6f..07ee0bc30bf 100644
--- a/apps/sim/lib/api/contracts/mothership-management-tools.test.ts
+++ b/apps/sim/lib/api/contracts/mothership-management-tools.test.ts
@@ -32,6 +32,11 @@ const examples = {
{ action: 'setup', connectorType: 'google_drive', accessMode: 'admin' },
{ action: 'approve', connectorType: 'google_drive', approved: true },
],
+ dashboards: [
+ { action: 'get' },
+ { action: 'set', content: 'title: Support\nblocks: []' },
+ { action: 'set', content: 'title: Support', expectedRevision: 'r1' },
+ ],
}
describe('management tool provider contract', () => {
diff --git a/apps/sim/lib/api/contracts/mothership-management-tools.ts b/apps/sim/lib/api/contracts/mothership-management-tools.ts
index 4da388cd0e8..e2b839b55af 100644
--- a/apps/sim/lib/api/contracts/mothership-management-tools.ts
+++ b/apps/sim/lib/api/contracts/mothership-management-tools.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { mothershipDashboardsInputSchema } from '@/lib/api/contracts/mothership-dashboards'
import { createWorkspaceInputSchema } from '@/lib/workspaces/create-input'
import { organizationSearchSourcesInputSchema } from './mothership-search-sources'
import { mothershipSettingsInputSchema } from './mothership-settings'
@@ -9,6 +10,14 @@ export const mothershipWorkspacesInputSchema = z.discriminatedUnion('action', [
/** Shared input contracts and availability; permission decisions remain in the domain use cases. */
export const managementToolContracts = [
+ {
+ id: 'dashboards',
+ route: 'sim',
+ scope: 'all',
+ description:
+ 'Read and save the selected workspace’s single dashboard, validated YAML over live tables. Load the create-dashboard skill for the schema. get returns content and revision, or nulls when the workspace has no dashboard yet; set with no revision creates it. Replacing an existing dashboard requires expectedRevision from get, so a concurrent edit is never overwritten. Use open_resource with type dashboard to show the result.',
+ inputSchema: mothershipDashboardsInputSchema,
+ },
{
id: 'workspaces',
route: 'sim',
diff --git a/apps/sim/lib/api/contracts/mothership-resource-tools.ts b/apps/sim/lib/api/contracts/mothership-resource-tools.ts
index 8d21ecd0db7..08de9dba462 100644
--- a/apps/sim/lib/api/contracts/mothership-resource-tools.ts
+++ b/apps/sim/lib/api/contracts/mothership-resource-tools.ts
@@ -10,7 +10,7 @@ export const openResourceInputSchema = z.strictObject({
resources: z
.array(
z.strictObject({
- type: z.enum(['workflow', 'table', 'knowledgebase', 'file', 'log']),
+ type: z.enum(['workflow', 'table', 'knowledgebase', 'file', 'dashboard', 'log']),
id: z.string().trim().min(1),
viewId: z
.string()
@@ -26,7 +26,7 @@ export const openResourceInputSchema = z.strictObject({
export const openResourceOutputSchema = z.object({
resources: z.array(
z.object({
- type: z.enum(['workflow', 'table', 'knowledgebase', 'file', 'log']),
+ type: z.enum(['workflow', 'table', 'knowledgebase', 'file', 'dashboard', 'log']),
id: z.string(),
title: z.string(),
workspaceId: z.string(),
diff --git a/apps/sim/lib/api/contracts/mothership-search-sources.ts b/apps/sim/lib/api/contracts/mothership-search-sources.ts
index b2634017d5c..573cb1d3179 100644
--- a/apps/sim/lib/api/contracts/mothership-search-sources.ts
+++ b/apps/sim/lib/api/contracts/mothership-search-sources.ts
@@ -3,7 +3,10 @@ import {
searchSourcePageSchema,
searchSourceSummarySchema,
} from '@/lib/api/contracts/knowledge/connectors'
-import { searchIntegrationApprovalSchema } from '@/lib/api/contracts/knowledge/search-integrations'
+import {
+ searchIntegrationApprovalSchema,
+ searchIntegrationStatusSchema,
+} from '@/lib/api/contracts/knowledge/search-integrations'
const connectorTypeSchema = z.string().trim().min(1).max(100)
@@ -36,7 +39,7 @@ export const organizationSearchSourcesOutputSchema = z.discriminatedUnion('actio
z.object({ action: z.literal('get'), source: searchSourceSummarySchema }),
z.object({
action: z.literal('providers'),
- providers: z.array(searchIntegrationApprovalSchema).max(100),
+ providers: z.array(searchIntegrationStatusSchema).max(100),
}),
z.object({
action: z.literal('setup'),
diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts
index 376098f9f30..8fd59a09d5b 100644
--- a/apps/sim/lib/api/contracts/organization-accounts.ts
+++ b/apps/sim/lib/api/contracts/organization-accounts.ts
@@ -102,8 +102,18 @@ export type OrganizationAccountConnectionResponse = z.output<
>
export const startOrganizationAccountConnectionBodySchema = z.union([
- z.object({ optionId: z.string().min(1).max(128) }).strict(),
- z.object({ mcpServerId: z.string().min(1).max(128) }).strict(),
+ z
+ .object({
+ optionId: z.string().min(1).max(128),
+ oauthCompletionId: z.string().uuid().optional(),
+ })
+ .strict(),
+ z
+ .object({
+ mcpServerId: z.string().min(1).max(128),
+ oauthCompletionId: z.string().uuid().optional(),
+ })
+ .strict(),
])
export type StartOrganizationAccountConnectionBody = z.input<
typeof startOrganizationAccountConnectionBodySchema
@@ -372,10 +382,18 @@ export const listPersonalOrganizationAccountsContract = defineRouteContract({
}),
},
})
+export const reconnectPersonalOrganizationAccountQuerySchema = z.object({
+ oauthCompletionId: z.string().uuid().optional(),
+})
+export type ReconnectPersonalOrganizationAccountQuery = z.input<
+ typeof reconnectPersonalOrganizationAccountQuerySchema
+>
+
export const reconnectPersonalOrganizationAccountContract = defineRouteContract({
method: 'POST',
path: '/api/users/me/organization-accounts/[credentialId]/reconnect',
params: z.object({ credentialId: z.string().min(1).max(128) }),
+ query: reconnectPersonalOrganizationAccountQuerySchema,
response: { mode: 'json', schema: organizationAccountConnectionResponseSchema },
})
export const disconnectPersonalOrganizationAccountContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts
index 4d1efa6dd63..424f17ceb10 100644
--- a/apps/sim/lib/api/contracts/subscription.ts
+++ b/apps/sim/lib/api/contracts/subscription.ts
@@ -325,17 +325,54 @@ export const billingSwitchPlanResponseSchema = z.object({
message: z.string().optional(),
})
-export const billingUpdateCostResponseSchema = z.object({
- success: z.literal(true),
- message: z.string().optional(),
- data: z.object({
- userId: z.string().optional(),
- cost: z.number().optional(),
- billingEnabled: z.boolean().optional(),
- processedAt: z.string(),
- requestId: z.string(),
- }),
+/**
+ * The upgrade card's payload: the JSON body of a `` tag in assistant text, which
+ * the chat renders as the usage card wherever that text appears (live, replayed, or reloaded).
+ * Sim decides the action and copy from the payer's plan; a worker ending a run at the usage
+ * limit writes the tag with this payload verbatim into its durable log.
+ */
+export const usageUpgradePayloadSchema = z.object({
+ reason: z.literal('usage_limit'),
+ action: z.enum(['upgrade_plan', 'increase_limit']),
+ message: z.string(),
})
+export type UsageUpgradePayload = z.infer
+
+/**
+ * The payer's standing after a cost callback, read through the cached execution usage gate. It
+ * sits at the top level of the body, beside `success`, where the worker's shared
+ * `BillingCallbackResult` reads it, on a 200 and on a duplicate 409 alike. A worker that
+ * predates the fields ignores them.
+ */
+export const billingUsageVerdictSchema = z.discriminatedUnion('usageExceeded', [
+ z.object({
+ /** The payer is within its usage limit, or its standing could not be read. */
+ usageExceeded: z.literal(false),
+ usageUpgrade: z.never().optional(),
+ }),
+ z.object({
+ /** The payer is over its usage limit; the worker pauses the run at its next step boundary. */
+ usageExceeded: z.literal(true),
+ /** The card the worker writes to its log. */
+ usageUpgrade: usageUpgradePayloadSchema,
+ }),
+])
+export type BillingUsageVerdict = z.infer
+
+export const billingUpdateCostResponseSchema = z
+ .object({
+ success: z.literal(true),
+ message: z.string().optional(),
+ data: z.object({
+ userId: z.string().optional(),
+ cost: z.number().optional(),
+ billingEnabled: z.boolean().optional(),
+ processedAt: z.string(),
+ requestId: z.string(),
+ }),
+ })
+ .and(billingUsageVerdictSchema)
+export type BillingUpdateCostResponse = z.infer
export const billingSwitchPlanContract = defineRouteContract({
method: 'POST',
diff --git a/apps/sim/lib/api/contracts/table-analytics.ts b/apps/sim/lib/api/contracts/table-analytics.ts
new file mode 100644
index 00000000000..30b175b644a
--- /dev/null
+++ b/apps/sim/lib/api/contracts/table-analytics.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod'
+import { defineRouteContract } from '@/lib/api/contracts'
+import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
+import { tableIdParamsSchema } from '@/lib/api/contracts/tables'
+import { ANALYTICS_MAX_ROWS, analyticsQuerySchema } from '@/lib/table/analytics/schema'
+
+export const queryTableAnalyticsBodySchema = z
+ .object({
+ workspaceId: workspaceIdSchema,
+ query: analyticsQuerySchema,
+ })
+ .strict()
+export const queryTableAnalyticsResponseSchema = z.object({
+ rows: z
+ .array(
+ z.record(
+ z.string().max(128),
+ z.union([z.string().max(8192), z.number(), z.boolean(), z.null()])
+ )
+ )
+ .max(ANALYTICS_MAX_ROWS),
+ columns: z.array(z.string().max(128)).max(12),
+ columnLabels: z.record(z.string().max(128), z.string().max(255)),
+ truncated: z.boolean(),
+ bucket: z.enum(['minute', 'hour', 'day', 'week', 'month', 'year']).nullable(),
+})
+export const queryTableAnalyticsContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/table/[tableId]/analytics',
+ params: tableIdParamsSchema,
+ body: queryTableAnalyticsBodySchema,
+ response: { mode: 'json', schema: queryTableAnalyticsResponseSchema },
+})
+export type QueryTableAnalyticsBody = z.input
+export type QueryTableAnalyticsResponse = z.output
diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts
index d5dbbef8c6f..8e36c324a53 100644
--- a/apps/sim/lib/api/contracts/v2/files.ts
+++ b/apps/sim/lib/api/contracts/v2/files.ts
@@ -758,6 +758,14 @@ export const v2ListFilesContract = defineRouteContract({
},
})
+export const v2CreatedFileSchema = v2FileSchema
+ .extend({ revision: writtenFileRevisionSchema })
+ .meta({
+ id: 'V2CreatedFile',
+ title: 'Created file',
+ description: 'A newly created workspace file, with the revision it produced.',
+ })
+
export const v2CreateFileContract = defineRouteContract({
method: 'POST',
path: '/api/v2/files',
@@ -765,7 +773,7 @@ export const v2CreateFileContract = defineRouteContract({
body: v2CreateFileBodySchema,
response: {
mode: 'json',
- schema: v2DataResponse(v2FileSchema),
+ schema: v2DataResponse(v2CreatedFileSchema),
status: 201,
},
})
diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts
index 8b96f5095fb..d0b268056d6 100644
--- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts
+++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts
@@ -208,9 +208,9 @@ const declaredRoutes = [
),
response: documentedSchema(
v2CreateFileContract.response.schema,
- 'V2FileResponse',
- 'File response',
- 'A single workspace file.',
+ 'V2CreatedFileResponse',
+ 'Created file response',
+ 'A newly created workspace file, with the revision it produced.',
[{ data: FILE_EXAMPLE }]
),
}
diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts
index 7af12d1a89e..9a62cd3d5de 100644
--- a/apps/sim/lib/api/contracts/v2/shared.ts
+++ b/apps/sim/lib/api/contracts/v2/shared.ts
@@ -544,7 +544,8 @@ export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRoo
maxLength: MAX_FOLDER_PATH_BYTES,
})
-function normalizeFolderPathInput(path: string): string {
+/** Adds the leading slash a folder path may omit; validation stays with the canonical schemas. */
+export function normalizeFolderPathInput(path: string): string {
return path.length === 0 || path.startsWith('/') ? path : `/${path}`
}
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 5e7bc09be32..f1b655e1ca9 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -35,6 +35,8 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
+import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff'
+import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
@@ -310,11 +312,28 @@ export const auth = betterAuth({
}
return { data: user }
},
- after: async (user) => {
+ after: async (user, context) => {
logger.info('[databaseHooks.user.create.after] User created, initializing stats', {
userId: user.id,
})
+ /**
+ * Only the marketing-consent-gated Freebuff tag writes the `bfcid`
+ * cookie, and `FreebuffClickIdGuard` deletes it once marketing consent
+ * is withdrawn or expires. Not awaited: the postback
+ * retries on its own and must never delay signup. The browser tag
+ * reports the same `eventId` on email signup and Freebuff dedupes.
+ */
+ const freebuffClickId = context?.getCookie(FREEBUFF_CLICK_ID_COOKIE)
+ if (freebuffClickId) {
+ void reportFreebuffConversion({
+ clickId: freebuffClickId,
+ eventType: 'signup_completed',
+ eventId: user.id,
+ occurredAt: user.createdAt,
+ })
+ }
+
try {
PlatformEvents.userSignedUp({
userId: user.id,
diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts
index 7571686ee5b..5b587a36342 100644
--- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts
+++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts
@@ -130,6 +130,21 @@ describe('checkUsageStatus', () => {
})
})
+ it('refuses on a ledger read failure but marks the answer unavailable', async () => {
+ mockGetBillingPeriodUsageCost.mockRejectedValueOnce(new Error('canceling statement'))
+
+ await expect(
+ checkUsageStatus('user-1', {
+ referenceId: 'user-1',
+ plan: 'free',
+ status: 'active',
+ seats: 1,
+ periodStart: new Date('2026-06-01T00:00:00.000Z'),
+ periodEnd: new Date('2026-07-01T00:00:00.000Z'),
+ })
+ ).resolves.toMatchObject({ isExceeded: true, unavailable: true })
+ })
+
it('preserves negative ledger-only personal usage', async () => {
const periodStart = new Date('2026-06-01T00:00:00.000Z')
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
@@ -198,6 +213,23 @@ describe('checkServerSideUsageLimits', () => {
mockGetBillingPeriodUsageCost.mockResolvedValue(125)
})
+ it('does not describe an unreadable ledger as a spent limit', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: false }])
+ mockGetBillingPeriodUsageCost.mockRejectedValueOnce(new Error('canceling statement'))
+
+ const result = await checkServerSideUsageLimits('user-1', {
+ referenceId: 'user-1',
+ plan: 'free',
+ status: 'active',
+ seats: 1,
+ periodStart: new Date('2026-06-01T00:00:00.000Z'),
+ periodEnd: new Date('2026-07-01T00:00:00.000Z'),
+ })
+
+ expect(result.isExceeded).toBe(true)
+ expect(result.message ?? '').not.toMatch(/\$/)
+ })
+
it('keeps blocked accounts blocked while reporting their real ledger usage', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'payment_failed' }])
const subscription = {
diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts
index f4ebe5bc66b..9ebb874e8b8 100644
--- a/apps/sim/lib/billing/calculations/usage-monitor.ts
+++ b/apps/sim/lib/billing/calculations/usage-monitor.ts
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
+import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
@@ -43,6 +44,11 @@ interface UsageData {
scope: 'user' | 'organization'
/** Present only when `scope === 'organization'`. */
organizationId: string | null
+ /**
+ * The ledger could not be read, so `isExceeded` is a fail-closed refusal rather than a
+ * measured one. Admission refuses on it; a run already under way treats it as unknown.
+ */
+ unavailable?: true
}
/**
@@ -183,6 +189,7 @@ export async function checkUsageStatus(
limit: 0,
scope: 'user',
organizationId: null,
+ unavailable: true,
}
}
}
@@ -352,7 +359,11 @@ export async function checkServerSideUsageLimits(
isExceeded: usageData.isExceeded,
currentUsage: usageData.currentUsage,
limit: usageData.limit,
- message: usageData.isExceeded ? exceededMessage : undefined,
+ message: usageData.unavailable
+ ? USAGE_UNAVAILABLE_MESSAGE
+ : usageData.isExceeded
+ ? exceededMessage
+ : undefined,
}
} catch (error) {
logger.error('Error in server-side usage limit check', {
diff --git a/apps/sim/lib/billing/constants.ts b/apps/sim/lib/billing/constants.ts
index c1fd884a293..a3db9fe9acf 100644
--- a/apps/sim/lib/billing/constants.ts
+++ b/apps/sim/lib/billing/constants.ts
@@ -103,3 +103,7 @@ export const ANNUAL_DISCOUNT_RATE = 0.15
* Effectively unlimited — any limit >= this threshold is treated as uncapped.
*/
export const ON_DEMAND_UNLIMITED = 999999
+
+/** Shown when usage could not be read, instead of a limit the read never measured. */
+export const USAGE_UNAVAILABLE_MESSAGE =
+ 'Usage could not be verified right now. Please try again in a moment.'
diff --git a/apps/sim/lib/billing/core/billing-attribution.test.ts b/apps/sim/lib/billing/core/billing-attribution.test.ts
index 5cec0fa1918..8e40d3456da 100644
--- a/apps/sim/lib/billing/core/billing-attribution.test.ts
+++ b/apps/sim/lib/billing/core/billing-attribution.test.ts
@@ -17,8 +17,10 @@ import {
assertBillingAttributionOwner,
assertBillingAttributionSnapshot,
billingAttributionsEqual,
+ checkAccountBillingBlocks,
checkAttributedBillingBlocks,
checkAttributedUsageLimits,
+ requireAccountBillingDecisionHeader,
requireBillingAttributionHeader,
requireBillingCallbackAttribution,
requireBillingRequestIdHeader,
@@ -195,6 +197,38 @@ describe('resolveBillingAttribution', () => {
})
})
+describe('account billing decision header', () => {
+ const decision = {
+ userId: 'actor',
+ billingEntity: { type: 'user', id: 'actor' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ source: 'stripe',
+ },
+ }
+ const header = (value: unknown) =>
+ new Headers({ 'x-sim-billing-account-decision': encodeURIComponent(JSON.stringify(value)) })
+
+ it('restores the admitted payer subscription', () => {
+ expect(
+ requireAccountBillingDecisionHeader(header({ ...decision, payerSubscriptionId: 'sub-1' }))
+ ).toMatchObject({ payerSubscriptionId: 'sub-1' })
+ expect(requireAccountBillingDecisionHeader(header(decision))).not.toHaveProperty(
+ 'payerSubscriptionId'
+ )
+ })
+
+ it.each([42, '', ' ', null, { id: 'sub-1' }])(
+ 'refuses a payer subscription of %j',
+ (payerSubscriptionId) => {
+ expect(() =>
+ requireAccountBillingDecisionHeader(header({ ...decision, payerSubscriptionId }))
+ ).toThrow('Account billing decision header is malformed')
+ }
+ )
+})
+
describe('serialized attribution boundaries', () => {
const attribution = {
actorUserId: 'actor-a',
@@ -333,6 +367,55 @@ describe('serialized attribution boundaries', () => {
})
})
+describe('checkAccountBillingBlocks', () => {
+ const decision = {
+ userId: 'actor',
+ billingEntity: { type: 'organization' as const, id: 'original-payer' },
+ billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
+ }
+
+ beforeEach(() => {
+ mockCheckBillingBlocked.mockReset().mockResolvedValue({ blocked: false })
+ mockCheckBillingEntityBlocked.mockReset().mockResolvedValue({ blocked: false })
+ })
+
+ it('refuses the exact actor and original payer when either is blocked', async () => {
+ mockCheckBillingBlocked.mockImplementation(async (userId: string) => ({
+ blocked: userId === 'actor',
+ }))
+ await expect(checkAccountBillingBlocks(decision)).resolves.toMatchObject({
+ blocked: true,
+ scope: 'actor',
+ })
+
+ mockCheckBillingBlocked.mockResolvedValue({ blocked: false })
+ mockCheckBillingEntityBlocked.mockImplementation(async (entity: { id: string }) => ({
+ blocked: entity.id === 'original-payer',
+ }))
+ await expect(checkAccountBillingBlocks(decision)).resolves.toMatchObject({
+ blocked: true,
+ scope: 'payer',
+ })
+ })
+
+ it('reports an actor block ahead of a payer block', async () => {
+ mockCheckBillingBlocked.mockResolvedValue({ blocked: true, message: 'Actor frozen.' })
+ mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: true, message: 'Payer frozen.' })
+ await expect(checkAccountBillingBlocks(decision)).resolves.toEqual({
+ blocked: true,
+ message: 'Actor frozen.',
+ scope: 'actor',
+ })
+ })
+
+ it('answers a personal payer from the actor standing alone', async () => {
+ mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: true })
+ await expect(
+ checkAccountBillingBlocks({ ...decision, billingEntity: { type: 'user', id: 'actor' } })
+ ).resolves.toMatchObject({ blocked: false })
+ })
+})
+
describe('checkAttributedUsageLimits', () => {
beforeEach(() => {
resetDbChainMock()
@@ -432,6 +515,28 @@ describe('checkAttributedUsageLimits', () => {
expect(mockCheckOrganizationMemberUsageLimit).not.toHaveBeenCalled()
})
+ it('names a billing block and an unreadable ledger apart from a spent limit', async () => {
+ mockCheckBillingBlocked.mockResolvedValueOnce({ blocked: true, message: 'Frozen.' })
+ await expect(checkAttributedUsageLimits(attribution)).resolves.toMatchObject({
+ isExceeded: true,
+ reason: 'billing_blocked',
+ })
+
+ mockCheckUsageStatus.mockResolvedValueOnce({
+ currentUsage: 0,
+ isExceeded: true,
+ limit: 0,
+ organizationId: null,
+ percentUsed: 100,
+ isWarning: false,
+ scope: 'user',
+ unavailable: true,
+ })
+ const unavailable = await checkAttributedUsageLimits(attribution)
+ expect(unavailable).toMatchObject({ isExceeded: true, reason: 'usage_unavailable' })
+ expect(unavailable.message ?? '').not.toMatch(/\$/)
+ })
+
it('returns payer exhaustion before checking the actor member cap', async () => {
mockCheckUsageStatus.mockResolvedValue({
currentUsage: 100,
diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts
index 6c205ca33bd..9e734dc487c 100644
--- a/apps/sim/lib/billing/core/billing-attribution.ts
+++ b/apps/sim/lib/billing/core/billing-attribution.ts
@@ -10,6 +10,7 @@ import {
checkUsageStatus,
} from '@/lib/billing/calculations/usage-monitor'
import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
+import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
@@ -96,6 +97,14 @@ export interface AccountBillingDecision {
readonly end: string
readonly source?: UsagePeriodSource
}
+ /**
+ * The payer's subscription at admission, so a run that outlives a Stripe period bills its
+ * later spend to the period it was spent in, as an attributed run's `payerSubscription` does.
+ * Absent for a payer without a subscription, and in decisions minted before it existed.
+ * If that subscription is replaced mid-run, spend stays in its period while the mid-run
+ * verdict judges the payer's current one, so the limit can only be under-enforced.
+ */
+ readonly payerSubscriptionId?: string
}
export interface ResolveBillingAttributionParams {
@@ -111,6 +120,11 @@ export interface AttributedUsageLimitsResult {
isExceeded: boolean
message?: string
scope?: 'actor' | 'payer' | 'member'
+ /**
+ * Why an `isExceeded` refusal is not a spent limit: the account is blocked (payment failed,
+ * dispute), or the payer's usage could not be read and the gate failed closed.
+ */
+ reason?: 'billing_blocked' | 'usage_unavailable'
payerUsage?: {
currentUsage: number
limit: number
@@ -540,6 +554,10 @@ function assertAccountBillingDecision(value: unknown): AccountBillingDecision {
) {
throw new Error('Account billing decision must contain a valid billing period source')
}
+ const payerSubscriptionId = value.payerSubscriptionId
+ if (payerSubscriptionId !== undefined && !isNonEmptyString(payerSubscriptionId)) {
+ throw new Error('Account billing decision must contain a valid payer subscription ID')
+ }
return Object.freeze({
userId: value.userId,
@@ -552,6 +570,7 @@ function assertAccountBillingDecision(value: unknown): AccountBillingDecision {
end: end.toISOString(),
...(source !== undefined ? { source } : {}),
}),
+ ...(payerSubscriptionId !== undefined ? { payerSubscriptionId } : {}),
})
}
@@ -720,6 +739,36 @@ function buildBillingAttributionSnapshot(params: {
})
}
+/**
+ * The same payer's attribution for its current usage period, for a run that outlived the period
+ * it was admitted in. The actor, workspace and payer are kept; only the payer's subscription,
+ * and so its period, is read again, and a subscription that no longer belongs to the payer is
+ * refused rather than re-selected.
+ */
+export async function refreshAttributionPeriod(
+ attribution: BillingAttributionSnapshot
+): Promise {
+ const validated = assertBillingAttributionSnapshot(attribution)
+ const payerSubscription = validated.organizationId
+ ? await getOrganizationSubscription(validated.organizationId, { onError: 'throw' })
+ : await getHighestPriorityPersonalSubscription(validated.billedAccountUserId, {
+ onError: 'throw',
+ })
+ const expectedReferenceId = validated.organizationId ?? validated.billedAccountUserId
+ if (payerSubscription && payerSubscription.referenceId !== expectedReferenceId) {
+ throw new Error(
+ `Resolved subscription ${payerSubscription.id} does not belong to payer ${expectedReferenceId}`
+ )
+ }
+ return buildBillingAttributionSnapshot({
+ actorUserId: validated.actorUserId,
+ workspaceId: validated.workspaceId,
+ billedAccountUserId: validated.billedAccountUserId,
+ organizationId: validated.organizationId,
+ payerSubscription,
+ })
+}
+
/**
* Resolves the payer from the workspace without consulting the actor's
* subscriptions or organization memberships.
@@ -901,6 +950,20 @@ export async function checkAttributedBillingBlocks(
return { blocked: false }
}
+/**
+ * The same freeze checks for a direct-v1 run: the actor's own account, then the payer saved in
+ * its admission decision, never one re-selected from the actor's current memberships.
+ */
+export async function checkAccountBillingBlocks(
+ decision: AccountBillingDecision
+): Promise {
+ const actorBlock = await checkBillingBlocked(decision.userId)
+ if (actorBlock.blocked) return { ...actorBlock, scope: 'actor' }
+ const payer = decision.billingEntity
+ if (payer.type === 'user' && payer.id === decision.userId) return actorBlock
+ return { ...(await checkBillingEntityBlocked(payer)), scope: 'payer' }
+}
+
/**
* Applies hosted billing gates in canonical order: actor account, workspace
* payer pool, then `(organizationId, actorUserId)` member cap.
@@ -919,6 +982,7 @@ export async function checkAttributedUsageLimits(
isExceeded: true,
message: billingBlock.message,
scope: billingBlock.scope,
+ reason: 'billing_blocked',
}
}
@@ -934,8 +998,9 @@ export async function checkAttributedUsageLimits(
if (payerUsage.isExceeded) {
const formattedUsage = payerUsage.currentUsage.toFixed(2)
const formattedLimit = payerUsage.limit.toFixed(2)
- const message =
- validatedAttribution.billingEntity.type === 'organization'
+ const message = payerUsage.unavailable
+ ? USAGE_UNAVAILABLE_MESSAGE
+ : validatedAttribution.billingEntity.type === 'organization'
? `Organization usage limit exceeded: $${formattedUsage} pooled of $${formattedLimit} organization limit. Ask a team admin to raise the organization usage limit to continue.`
: `Usage limit exceeded: $${formattedUsage} used of $${formattedLimit} limit. Please upgrade your plan or raise your usage limit to continue.`
@@ -944,6 +1009,7 @@ export async function checkAttributedUsageLimits(
message,
scope: 'payer',
payerUsage: payerSnapshot,
+ ...(payerUsage.unavailable ? { reason: 'usage_unavailable' as const } : {}),
}
}
diff --git a/apps/sim/lib/billing/core/mid-run-usage.ts b/apps/sim/lib/billing/core/mid-run-usage.ts
new file mode 100644
index 00000000000..96393ba26bc
--- /dev/null
+++ b/apps/sim/lib/billing/core/mid-run-usage.ts
@@ -0,0 +1,244 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { LRUCache } from 'lru-cache'
+import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
+import { getOrganizationSubscription } from '@/lib/billing/core/billing'
+import {
+ type AccountBillingDecision,
+ type AttributedUsageLimitsResult,
+ type BillingAttributionSnapshot,
+ checkAccountBillingBlocks,
+ refreshAttributionPeriod,
+} from '@/lib/billing/core/billing-attribution'
+import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
+import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
+import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period'
+import {
+ checkExecutionUsageLimits,
+ USAGE_GATE_SETTLE_TIMEOUT_MS,
+ USAGE_GATE_TTL_MS,
+} from '@/lib/billing/core/usage-gate-cache'
+import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
+import { coalesceLocally } from '@/lib/concurrency/singleflight'
+import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
+
+const logger = createLogger('MidRunUsage')
+
+/**
+ * A run's standing while it is under way, read through the execution usage gate:
+ * - `exceeded`: the payer (or the actor's member cap) spent its limit; the run pauses with the
+ * upgrade card. A direct-v1 verdict carries the payer it read, so the card names that payer's
+ * plan rather than the actor's.
+ * - `blocked`: the account is blocked (payment failed, dispute); the run is refused as a blocked
+ * account, never with the upgrade card.
+ * - `unknown`: usage, or the payer's current period, could not be read. Admission fails closed
+ * on an unreadable ledger, but a run already under way continues: a database blip must not
+ * end a paying user's long run, and the next step or re-check reads again.
+ */
+export type MidRunUsageVerdict =
+ | { status: 'within' }
+ | {
+ status: 'exceeded'
+ scope?: AttributedUsageLimitsResult['scope']
+ payer?: UsageUpgradePayer
+ }
+ | { status: 'blocked'; message?: string }
+ | { status: 'unknown' }
+
+/**
+ * How long a payer's current period stays cached for mid-run checks. Every model step settles a
+ * cost callback that reads it; a subscription period change (rollover, anchor reset) reaches the
+ * check within this long.
+ */
+const CURRENT_PERIOD_TTL_MS = 60 * 1000
+
+const currentPeriodCache = new LRUCache({
+ max: 10_000,
+ ttl: CURRENT_PERIOD_TTL_MS,
+})
+
+function currentPeriodKey(attribution: BillingAttributionSnapshot): string {
+ return [
+ attribution.actorUserId,
+ attribution.workspaceId ?? '',
+ attribution.organizationId ?? '',
+ attribution.billedAccountUserId,
+ ].join(':')
+}
+
+/** The admitted payer's attribution for its current subscription period. */
+async function currentAttribution(
+ attribution: BillingAttributionSnapshot
+): Promise {
+ const key = currentPeriodKey(attribution)
+ const cached = currentPeriodCache.get(key)
+ // A cached period that has since ended is stale: the payer may already be in the next one.
+ if (cached && !periodHasEnded(cached)) return cached
+ const current = await refreshAttributionPeriod(attribution)
+ currentPeriodCache.set(key, current)
+ return current
+}
+
+/** Mirrors the cost callback's rollover gate: only a Stripe period rolls forward. */
+function rollsIntoCurrentPeriod(period: { source?: string }): boolean {
+ return period.source === 'stripe'
+}
+
+function periodHasEnded(attribution: BillingAttributionSnapshot): boolean {
+ return Date.now() >= new Date(attribution.billingPeriod.end).getTime()
+}
+
+async function readGateVerdict(
+ attribution: BillingAttributionSnapshot
+): Promise {
+ let usage: AttributedUsageLimitsResult
+ try {
+ usage = await checkExecutionUsageLimits(attribution)
+ } catch (error) {
+ logger.warn('Mid-run usage read failed; continuing the run', {
+ error: getErrorMessage(error),
+ })
+ return { status: 'unknown' }
+ }
+ if (!usage.isExceeded) return { status: 'within' }
+ if (usage.reason === 'billing_blocked') {
+ return { status: 'blocked', ...(usage.message ? { message: usage.message } : {}) }
+ }
+ if (usage.reason === 'usage_unavailable') {
+ logger.warn('Mid-run usage could not be read; continuing the run')
+ return { status: 'unknown' }
+ }
+ return { status: 'exceeded', ...(usage.scope ? { scope: usage.scope } : {}) }
+}
+
+/**
+ * Judges a run against the period its charges land in. A Stripe-period payer's charges roll into
+ * whatever period the subscription is in now (a rollover or an early anchor reset included), so
+ * such a run is judged against the payer's CURRENT period. A current period that cannot be read,
+ * or that ends before its verdict is read, makes the verdict unknown, so the run continues and the
+ * next callback judges the next period. Any other payer's charges stay in the admitted
+ * period (a reporting window, or the open default one), so that period is judged, even after it
+ * ends.
+ */
+export async function readMidRunUsageVerdict(
+ attribution: BillingAttributionSnapshot
+): Promise {
+ if (!isHosted || !isBillingEnabled) return { status: 'within' }
+ if (!rollsIntoCurrentPeriod(attribution.billingPeriod)) return readGateVerdict(attribution)
+ let judged: BillingAttributionSnapshot
+ try {
+ judged = await currentAttribution(attribution)
+ } catch (error) {
+ logger.warn('Current billing period could not be read; continuing the run', {
+ error: getErrorMessage(error),
+ })
+ return { status: 'unknown' }
+ }
+ if (periodHasEnded(judged)) return { status: 'unknown' }
+ const verdict = await readGateVerdict(judged)
+ // A period that ended during the read is judged at the next callback, which reloads it.
+ return periodHasEnded(judged) ? { status: 'unknown' } : verdict
+}
+
+/**
+ * Admitted direct-v1 verdicts, served for the execution gate's TTL like
+ * {@link checkExecutionUsageLimits} serves attributed ones: the worker re-validates a run on
+ * every resume leg, and each uncached read sums the payer's ledger for the period. Only a
+ * `within` verdict is stored, so a refusal or an unreadable ledger is always read again.
+ */
+const accountVerdictCache = new LRUCache({
+ max: 10_000,
+ ttl: USAGE_GATE_TTL_MS,
+})
+
+/**
+ * The same verdict for a direct-v1 run billed to an account decision rather than an attributed
+ * payer, in the gate's order: a blocked actor or payer first, then the payer's spend. The payer
+ * is the one saved in the decision at admission, never re-selected from the actor's current
+ * memberships, and the period judged is the one its charges land in, as for attributed runs.
+ */
+export async function readMidRunAccountUsageVerdict(
+ decision: AccountBillingDecision
+): Promise {
+ if (!isHosted || !isBillingEnabled) return { status: 'within' }
+ try {
+ const payer = decision.billingEntity
+ const subscription =
+ payer.type === 'organization'
+ ? await getOrganizationSubscription(payer.id, { onError: 'throw' })
+ : await getHighestPriorityPersonalSubscription(payer.id, { onError: 'throw' })
+ const billingPeriod = rollsIntoCurrentPeriod(decision.billingPeriod)
+ ? (resolveSubscriptionUsagePeriod(subscription) ?? {
+ ...defaultBillingPeriod(),
+ source: 'default' as const,
+ })
+ : {
+ start: new Date(decision.billingPeriod.start),
+ end: new Date(decision.billingPeriod.end),
+ source: decision.billingPeriod.source ?? ('default' as const),
+ }
+ const key = [
+ payer.type,
+ payer.id,
+ billingPeriod.start.toISOString(),
+ billingPeriod.end.toISOString(),
+ billingPeriod.source,
+ decision.userId,
+ subscription?.id ?? '',
+ subscription?.plan ?? '',
+ subscription?.status ?? '',
+ subscription?.seats ?? '',
+ ].join(':')
+ const cached = accountVerdictCache.get(key)
+ if (cached) return cached
+ const block = await checkAccountBillingBlocks(decision)
+ if (block.blocked) {
+ return { status: 'blocked', ...(block.message ? { message: block.message } : {}) }
+ }
+ // An organization payer without a subscription stays organization-scoped on the free plan,
+ // as `toUsageLimitSubscription` does for attributed runs, never the actor's personal ledger.
+ const usageSubscription =
+ subscription ??
+ (payer.type === 'organization'
+ ? {
+ referenceId: payer.id,
+ plan: 'free',
+ status: null,
+ seats: null,
+ periodStart: billingPeriod.start,
+ periodEnd: billingPeriod.end,
+ }
+ : null)
+ const usage = await coalesceLocally(
+ `mid-run-account-usage:${key}`,
+ () =>
+ checkUsageStatus(decision.userId, usageSubscription, {
+ billingEntity: payer,
+ billingPeriod,
+ }),
+ USAGE_GATE_SETTLE_TIMEOUT_MS
+ )
+ if (usage.unavailable) return { status: 'unknown' }
+ if (usage.isExceeded) {
+ return {
+ status: 'exceeded',
+ scope: 'payer',
+ payer: { billingEntity: payer, payerSubscription: subscription },
+ }
+ }
+ const within: MidRunUsageVerdict = { status: 'within' }
+ accountVerdictCache.set(key, within)
+ return within
+ } catch (error) {
+ logger.warn('Mid-run account usage read failed; continuing the run', {
+ error: getErrorMessage(error),
+ })
+ return { status: 'unknown' }
+ }
+}
+
+/** Drops every cached current period and account verdict. Test seam; never called in production code. */
+export function resetMidRunUsageCaches(): void {
+ currentPeriodCache.clear()
+ accountVerdictCache.clear()
+}
diff --git a/apps/sim/lib/billing/core/usage-analytics.ts b/apps/sim/lib/billing/core/usage-analytics.ts
index 92e6b50f9c7..49c6b4417aa 100644
--- a/apps/sim/lib/billing/core/usage-analytics.ts
+++ b/apps/sim/lib/billing/core/usage-analytics.ts
@@ -8,7 +8,7 @@ import {
} from '@/lib/billing/core/reporting-period'
import type { BillingEntity } from '@/lib/billing/core/usage-log'
import { zonedWallClockToUtc } from '@/lib/core/utils/timezone'
-import { STREAM_TIMEOUT_MS } from '@/lib/mothership/constants'
+import { CHAT_RUN_DEADLINE_MS } from '@/lib/mothership/constants'
/**
* Pure half of organization usage analytics: window resolution, the ledger scope
@@ -510,11 +510,15 @@ export function usageBucketTimestamps(
* How long after a stretch of time ends before its ledger rows are final.
*
* Rows are stamped when inserted, but a cumulative model charge tops up its row's
- * cost in place for as long as its stream runs — which {@link STREAM_TIMEOUT_MS}
- * caps — plus the retry flushes that follow it. Past the cap and this margin a day or
- * hour can no longer change and is treated as settled.
+ * cost in place for as long as its run lasts — which the worker's run deadline
+ * ({@link CHAT_RUN_DEADLINE_MS}) caps — plus the retry flushes that follow it. Past
+ * the cap and this margin a day or hour can no longer change and is treated as settled.
+ *
+ * Without a run deadline a Chat turn can top up its row for longer than that, so a
+ * settled hour's cached aggregate can under-report that turn's later spend. This is
+ * display only: invoices, threshold billing, and the usage gate read live ledger sums.
*/
-export const USAGE_SETTLE_MS = STREAM_TIMEOUT_MS + 2 * 60 * 60 * 1000
+export const USAGE_SETTLE_MS = CHAT_RUN_DEADLINE_MS + 2 * 60 * 60 * 1000
const HOUR_MS = 60 * 60 * 1000
diff --git a/apps/sim/lib/billing/core/usage-log.integration.ts b/apps/sim/lib/billing/core/usage-log.integration.ts
index 7b8da8fb23f..9996d2dd698 100644
--- a/apps/sim/lib/billing/core/usage-log.integration.ts
+++ b/apps/sim/lib/billing/core/usage-log.integration.ts
@@ -9,8 +9,9 @@ import type { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
import { getPostgresErrorCode } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
-import { sql } from 'drizzle-orm'
+import { eq, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -20,15 +21,21 @@ const databaseUrl = readTestDatabaseUrl()
vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} }))
vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: vi.fn() }))
-vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: vi.fn() }))
+vi.mock('@/lib/billing/subscriptions/utils', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isOrgScopedSubscription: vi.fn(),
+}))
import {
CumulativeUsageContextMismatchError,
+ CumulativeUsagePeriodClosedError,
getBillingPeriodUsageCost,
getBillingPeriodUsageCostByUser,
+ getStampedPeriodRangeUsageCostByUser,
type RecordCumulativeUsageParams,
recordCumulativeUsage,
} from '@/lib/billing/core/usage-log'
+import { claimTerminalPeriod } from '@/lib/billing/cycle-close'
const require = createRequire(import.meta.url)
const commonJsPostgres = require('postgres') as typeof postgres
@@ -119,7 +126,11 @@ describe('Cumulative billing with PostgreSQL', () => {
);
CREATE UNIQUE INDEX usage_log_event_key_unique ON usage_log(event_key)
WHERE event_key IS NOT NULL;
- CREATE TABLE driver_probe (id text PRIMARY KEY)
+ CREATE TABLE driver_probe (id text PRIMARY KEY);
+ CREATE TABLE subscription (
+ id text PRIMARY KEY, period_start timestamp, period_end timestamp,
+ last_closed_period_start timestamp
+ )
`)
transaction.mockImplementation(async (callback: (tx: Transaction) => Promise) => {
const pause = nextPause
@@ -142,6 +153,7 @@ describe('Cumulative billing with PostgreSQL', () => {
beforeEach(async () => {
nextPause = undefined
await connection`truncate usage_log`
+ await connection`truncate subscription`
})
it.each([
@@ -216,12 +228,12 @@ describe('Cumulative billing with PostgreSQL', () => {
expect(recovered.billed).toBe(true)
expect(recovered.delta).toBeCloseTo(0.8 - initial, 9)
expect(recovered.total).toBe(0.8)
- expect(await recordCumulativeUsage(usage(0.8))).toEqual({
+ expect(await recordCumulativeUsage(usage(0.8))).toMatchObject({
billed: false,
delta: 0,
total: 0.8,
})
- expect(await recordCumulativeUsage(usage(0.3))).toEqual({
+ expect(await recordCumulativeUsage(usage(0.3))).toMatchObject({
billed: false,
delta: 0,
total: 0.8,
@@ -245,7 +257,11 @@ describe('Cumulative billing with PostgreSQL', () => {
)
)
expect(await ledgerRows()).toHaveLength(33)
- expect(await recordCumulativeUsage(usage(0.8))).toEqual({ billed: false, delta: 0, total: 0.8 })
+ expect(await recordCumulativeUsage(usage(0.8))).toMatchObject({
+ billed: false,
+ delta: 0,
+ total: 0.8,
+ })
})
it('reads committed pooled and member charges freshly after concurrent executions', async () => {
@@ -305,4 +321,324 @@ describe('Cumulative billing with PostgreSQL', () => {
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.8' }])
}
)
+
+ it("counts a reporting run's top-ups after its window ends in that window, and a later run's charges in the next", async () => {
+ const payer = { type: 'organization', id: 'payer' } as const
+ const dayMs = 24 * 60 * 60 * 1000
+ // A reporting window is summed by when each row was created; the stamped period only binds a
+ // request's rows to each other.
+ const stamp = {
+ start: new Date('2026-01-01'),
+ end: new Date('2027-01-01'),
+ source: 'reporting' as const,
+ }
+ await recordCumulativeUsage({ ...usage(0.4, 'update-cost:long-run'), billingPeriod: stamp })
+ const [first] = await database
+ .select({ createdAt: schema.usageLog.createdAt })
+ .from(schema.usageLog)
+ .where(eq(schema.usageLog.eventKey, 'update-cost:long-run'))
+ // The admitted window ends right after the run's first charge, and every later write starts
+ // once the database clock has passed that boundary.
+ const boundary = new Date(first.createdAt.getTime() + 1)
+ for (;;) {
+ const [{ passed }] = await connection<{ passed: boolean }[]>`
+ select clock_timestamp()::timestamp > created_at + interval '1 millisecond' as passed
+ from usage_log where event_key = 'update-cost:long-run'
+ `
+ if (passed) break
+ }
+
+ await recordCumulativeUsage({ ...usage(1, 'update-cost:long-run'), billingPeriod: stamp })
+ await recordCumulativeUsage({ ...usage(0.25, 'update-cost:next-run'), billingPeriod: stamp })
+
+ const windowTotal = (start: Date, end: Date) =>
+ getBillingPeriodUsageCost(payer, { start, end, source: 'reporting' }, undefined, database)
+ expect(await windowTotal(new Date(boundary.getTime() - 30 * dayMs), boundary)).toBeCloseTo(1, 9)
+ expect(await windowTotal(boundary, new Date(boundary.getTime() + 30 * dayMs))).toBeCloseTo(
+ 0.25,
+ 9
+ )
+ })
+
+ describe('a request that outlives its billing period', () => {
+ // Past periods: the old period's row is written under the subscription lock only once
+ // that period has ended.
+ const periods = [
+ new Date('2025-09-01T00:00:00.000Z'),
+ new Date('2025-10-01T00:00:00.000Z'),
+ new Date('2025-11-01T00:00:00.000Z'),
+ new Date('2025-12-01T00:00:00.000Z'),
+ ]
+ const payer = { type: 'organization', id: 'payer' } as const
+
+ /** Moves the subscription to a window whose predecessor the cycle close has settled. */
+ async function setSubscriptionWindow(start: Date, end: Date) {
+ await connection`
+ insert into subscription (id, period_start, period_end, last_closed_period_start)
+ values ('sub-1', ${start.toISOString()}::timestamptz at time zone 'UTC', ${end.toISOString()}::timestamptz at time zone 'UTC', ${start.toISOString()}::timestamptz at time zone 'UTC')
+ on conflict (id) do update
+ set period_start = excluded.period_start, period_end = excluded.period_end,
+ last_closed_period_start = greatest(
+ subscription.last_closed_period_start, excluded.last_closed_period_start
+ )
+ `
+ }
+
+ async function setSubscriptionPeriod(index: number) {
+ await setSubscriptionWindow(periods[index], periods[index + 1])
+ }
+
+ function charge(cost: number, frozen = { start: periods[0], end: periods[1] }) {
+ return recordCumulativeUsage({
+ ...usage(cost),
+ billingPeriod: frozen,
+ payerSubscriptionId: 'sub-1',
+ })
+ }
+
+ /** What the cycle close invoices for one period: the ledger rows stamped with it. */
+ async function stampedWindowTotal(from: Date, to: Date) {
+ const byUser = await getStampedPeriodRangeUsageCostByUser(
+ payer,
+ { from, to },
+ undefined,
+ database
+ )
+ return [...byUser.values()].reduce((total, cost) => total + cost, 0)
+ }
+
+ function stampedTotal(index: number) {
+ return stampedWindowTotal(periods[index], periods[index + 1])
+ }
+
+ it('invoices a charge that spans a period close exactly once in total', async () => {
+ await setSubscriptionPeriod(0)
+ expect(await charge(0.4)).toMatchObject({ billed: true, total: 0.4 })
+
+ await setSubscriptionPeriod(1)
+ const closedTotal = await stampedTotal(0)
+ expect(closedTotal).toBeCloseTo(0.4, 9)
+
+ const afterClose = await charge(1)
+ expect(afterClose).toMatchObject({ billed: true, total: 1 })
+ expect(afterClose.billingPeriod).toEqual({ start: periods[1], end: periods[2] })
+ expect(await charge(0.9)).toMatchObject({ billed: false, total: 1 })
+ expect(await charge(1.3)).toMatchObject({ billed: true, total: 1.3 })
+ expect(await charge(1.3)).toMatchObject({ billed: false, total: 1.3 })
+
+ await setSubscriptionPeriod(2)
+ expect(await charge(1.5)).toMatchObject({ billed: true, total: 1.5 })
+
+ expect(await stampedTotal(0)).toBeCloseTo(closedTotal, 9)
+ expect(await stampedTotal(1)).toBeCloseTo(0.9, 9)
+ expect(await stampedTotal(2)).toBeCloseTo(0.2, 9)
+ const invoiced = (await stampedTotal(0)) + (await stampedTotal(1)) + (await stampedTotal(2))
+ expect(invoiced).toBeCloseTo(1.5, 9)
+ })
+
+ it('gives each period row only the tokens spent after the rows before it', async () => {
+ await setSubscriptionPeriod(0)
+ await recordCumulativeUsage({
+ ...usage(0.4),
+ billingPeriod: { start: periods[0], end: periods[1] },
+ payerSubscriptionId: 'sub-1',
+ })
+ await setSubscriptionPeriod(1)
+ await recordCumulativeUsage({
+ ...usage(1),
+ billingPeriod: { start: periods[0], end: periods[1] },
+ payerSubscriptionId: 'sub-1',
+ metadata: { inputTokens: 25, outputTokens: 12 },
+ })
+
+ const rows = await connection<{ event_key: string; metadata: Record }[]>`
+ select event_key, metadata from usage_log order by event_key
+ `
+ expect(rows.map((row) => [row.event_key, row.metadata])).toEqual([
+ ['update-cost:shared-request', { inputTokens: 10, outputTokens: 5 }],
+ ['update-cost:shared-request@1', { inputTokens: 15, outputTokens: 7 }],
+ ])
+ })
+
+ it('never stamps a charge into a period earlier than its latest row', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+ await setSubscriptionPeriod(1)
+ await charge(1)
+ await setSubscriptionPeriod(0)
+ expect(await charge(1.2)).toMatchObject({ billed: true, total: 1.2 })
+ expect(await stampedTotal(0)).toBeCloseTo(0.4, 9)
+ expect(await stampedTotal(1)).toBeCloseTo(0.8, 9)
+ })
+
+ it('stamps a first charge that lands after the close into the current period', async () => {
+ await setSubscriptionPeriod(1)
+ expect(await charge(0.7)).toMatchObject({ billed: true, total: 0.7 })
+ expect(await charge(0.9)).toMatchObject({ billed: true, total: 0.9 })
+ expect(await stampedTotal(0)).toBe(0)
+ expect(await stampedTotal(1)).toBeCloseTo(0.9, 9)
+ })
+
+ it('holds the period advance until an in-flight top-up of the old period commits', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+ const pause = pauseNextTransaction()
+ const inFlight = charge(0.6)
+ try {
+ await pause.reached.promise
+ const advance = await connection
+ .begin(async (tx) => {
+ await tx`select set_config('lock_timeout', '300ms', true)`
+ await tx`update subscription set period_start = ${periods[1].toISOString()}::timestamptz at time zone 'UTC' where id = 'sub-1'`
+ })
+ .catch((error: unknown) => error)
+ expect(getPostgresErrorCode(advance)).toBe('55P03')
+ } finally {
+ pause.release.resolve()
+ await inFlight
+ }
+ expect(await stampedTotal(0)).toBeCloseTo(0.6, 9)
+ })
+
+ it('rolls into a period whose start moved forward before the old period ended', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+ const resetStart = new Date('2025-09-15T00:00:00.000Z')
+ const resetEnd = new Date('2025-10-15T00:00:00.000Z')
+ await setSubscriptionWindow(resetStart, resetEnd)
+
+ expect(await charge(1)).toMatchObject({
+ billed: true,
+ billingPeriod: { start: resetStart, end: resetEnd },
+ })
+ expect(await stampedTotal(0)).toBeCloseTo(0.4, 9)
+ expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(0.6, 9)
+ })
+
+ it('keeps billing a request whose period start moved forward before its first charge', async () => {
+ const resetStart = new Date('2025-09-15T00:00:00.000Z')
+ const resetEnd = new Date('2025-10-15T00:00:00.000Z')
+ await setSubscriptionWindow(resetStart, resetEnd)
+
+ expect(await charge(0.4)).toMatchObject({ billed: true, total: 0.4 })
+ expect(await charge(1)).toMatchObject({
+ billed: true,
+ total: 1,
+ billingPeriod: { start: resetStart, end: resetEnd },
+ })
+ expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '1' }])
+ expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(1, 9)
+ })
+
+ it('refuses a request admitted after the period its first charge was stamped with', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+
+ await expect(charge(1, { start: periods[1], end: periods[2] })).rejects.toMatchObject({
+ name: CumulativeUsageContextMismatchError.name,
+ mismatchedFields: ['billing period'],
+ })
+ expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }])
+ })
+
+ it('refuses a charge that would roll into a period the terminal settlement already summed', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+ await setSubscriptionPeriod(1)
+ await connection`
+ update subscription
+ set last_closed_period_start = ${periods[2].toISOString()}::timestamptz at time zone 'UTC'
+ `
+
+ await expect(charge(1)).rejects.toBeInstanceOf(CumulativeUsagePeriodClosedError)
+ expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }])
+ })
+
+ /**
+ * Resolves true once a session waits on a row lock of the subscription table, or false once
+ * `work` settles without anyone waiting, so a missing lock fails instead of hanging.
+ */
+ async function waitsOnSubscriptionRow(work: Promise) {
+ let settled = false
+ work.then(
+ () => {
+ settled = true
+ },
+ () => {
+ settled = true
+ }
+ )
+ while (!settled) {
+ const [row] = await connection<{ waiting: boolean }[]>`
+ select exists (
+ select 1 from pg_locks
+ where locktype = 'tuple' and relation = 'subscription'::regclass
+ ) as waiting
+ `
+ if (row.waiting) return true
+ await sleep(10)
+ }
+ return false
+ }
+
+ it('makes the terminal claim wait for an in-flight charge, so the final sum includes it', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+ const pause = pauseNextTransaction()
+ const inFlight = charge(0.6)
+ let claim: Promise = Promise.resolve()
+ try {
+ await pause.reached.promise
+ claim = claimTerminalPeriod('sub-1')
+ expect(await waitsOnSubscriptionRow(claim)).toBe(true)
+ } finally {
+ pause.release.resolve()
+ await inFlight
+ await claim
+ }
+ expect(await stampedTotal(0)).toBeCloseTo(0.6, 9)
+ await expect(charge(0.8)).rejects.toBeInstanceOf(CumulativeUsagePeriodClosedError)
+ })
+
+ it('refuses a charge that waited on an in-flight terminal claim', async () => {
+ await setSubscriptionPeriod(0)
+ await charge(0.4)
+ const pause = pauseNextTransaction()
+ const claim = claimTerminalPeriod('sub-1')
+ let late: Promise = Promise.resolve()
+ try {
+ await pause.reached.promise
+ late = charge(0.6)
+ expect(await waitsOnSubscriptionRow(late)).toBe(true)
+ } finally {
+ pause.release.resolve()
+ await claim
+ }
+ await expect(late).rejects.toBeInstanceOf(CumulativeUsagePeriodClosedError)
+ expect(await stampedTotal(0)).toBeCloseTo(0.4, 9)
+ })
+
+ it('holds an early period-start move until an in-flight top-up commits', async () => {
+ const start = new Date(Date.now() - 24 * 60 * 60 * 1000)
+ const end = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
+ await setSubscriptionWindow(start, end)
+ await charge(0.4, { start, end })
+ const pause = pauseNextTransaction()
+ const inFlight = charge(0.6, { start, end })
+ try {
+ await pause.reached.promise
+ const reset = await connection
+ .begin(async (tx) => {
+ await tx`select set_config('lock_timeout', '300ms', true)`
+ await tx`update subscription set period_start = now() at time zone 'UTC' where id = 'sub-1'`
+ })
+ .catch((error: unknown) => error)
+ expect(getPostgresErrorCode(reset)).toBe('55P03')
+ } finally {
+ pause.release.resolve()
+ await inFlight
+ }
+ expect(await stampedWindowTotal(start, end)).toBeCloseTo(0.6, 9)
+ })
+ })
})
diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts
index f3ccfba347e..3c87c10b9fa 100644
--- a/apps/sim/lib/billing/core/usage-log.test.ts
+++ b/apps/sim/lib/billing/core/usage-log.test.ts
@@ -280,7 +280,7 @@ describe('recordCumulativeUsage', () => {
eventKey: 'update-cost:msg-1-billing',
metadata: { inputTokens: 100, outputTokens: 5 },
})
- expect(result).toEqual({ billed: true, delta: 0.3474447, total: 0.3474447 })
+ expect(result).toMatchObject({ billed: true, delta: 0.3474447, total: 0.3474447 })
expect(mockInsert).toHaveBeenCalledTimes(1)
expect(mockUpdate).not.toHaveBeenCalled()
expect(mockValues.mock.calls[0][0][0]).toMatchObject({
@@ -315,7 +315,7 @@ describe('recordCumulativeUsage', () => {
cost: 0.4662453,
eventKey: 'update-cost:msg-1-billing',
})
- expect(result).toEqual({ billed: false, delta: 0, total: 0.4662453 })
+ expect(result).toMatchObject({ billed: false, delta: 0, total: 0.4662453 })
expect(updateSet).not.toHaveBeenCalled()
expect(mockInsert).not.toHaveBeenCalled()
})
diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts
index 5a845acad67..204a10f4e24 100644
--- a/apps/sim/lib/billing/core/usage-log.ts
+++ b/apps/sim/lib/billing/core/usage-log.ts
@@ -1,9 +1,11 @@
import { createHash } from 'node:crypto'
import { db, dbReplica } from '@sim/db'
-import { usageLog, workflow } from '@sim/db/schema'
+import { subscription as subscriptionTable, usageLog, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import { toNumberOrNull } from '@sim/utils/coerce'
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
+import { toRecordOrNull } from '@sim/utils/object'
import { and, desc, eq, gte, inArray, lt, lte, notInArray, or, sql } from 'drizzle-orm'
import {
type CursorKey,
@@ -583,6 +585,23 @@ export interface RecordCumulativeUsageParams {
/** Stable per-request key; the single ledger row is keyed on this. */
eventKey: string
metadata?: UsageLogMetadata
+ /**
+ * The Stripe-period subscription that pays for this request. When given, a top-up that
+ * arrives after that subscription has moved past the period of the request's latest row is
+ * recorded in a new row stamped with the subscription's current period, so a request that
+ * outlives its billing period is invoiced by the period it was spent in rather than topping up
+ * a period that has already been closed. A charge into a period the subscription's close marker
+ * has already passed (its terminal settlement) throws {@link CumulativeUsagePeriodClosedError}.
+ * Omit it for reporting-window and free payers.
+ *
+ * Mixed versions: code that predates period rows reads only the request key. If such code
+ * (during a deploy, or after a rollback) handles a later callback for a request that already
+ * has period rows, it re-adds those rows' amount to the first row. That only double-counts
+ * when it lands between the rollover and that period's close, which waits at least an hour,
+ * and only for runs spanning a rollover; the exposure is one run's post-rollover spend, cents
+ * to dollars.
+ */
+ payerSubscriptionId?: string
}
export interface RecordCumulativeUsageResult {
@@ -592,6 +611,57 @@ export interface RecordCumulativeUsageResult {
delta: number
/** The request's recorded cumulative cost after this flush. */
total: number
+ /** The billing period of the row this flush wrote to, or of the request's latest row. */
+ billingPeriod: { start: Date; end: Date }
+}
+
+/**
+ * The most period rows one request may span: its first row plus one per later billing period.
+ * A request still billing twelve periods after it started is refused rather than scanned.
+ */
+const MAX_CUMULATIVE_PERIOD_ROWS = 12
+
+/**
+ * The ledger key of the `index`-th period a cumulative request rolled into; 0 is the request key.
+ * The cost callback refuses a request key containing `@`, so these never collide with another
+ * request's.
+ */
+function cumulativePeriodEventKey(eventKey: string, index: number): string {
+ return index === 0 ? eventKey : `${eventKey}@${index}`
+}
+
+/** Decimal places kept when period row costs are summed or subtracted as floats. */
+const PERIOD_COST_DECIMALS = 12
+
+function sumLedgerCost(rows: readonly { cost: string }[]): number {
+ if (rows.length <= 1) return rows[0] ? Number.parseFloat(rows[0].cost) : 0
+ const total = rows.reduce((sum, row) => sum + Number.parseFloat(row.cost), 0)
+ return Number(total.toFixed(PERIOD_COST_DECIMALS))
+}
+
+const CUMULATIVE_TOKEN_FIELDS = ['inputTokens', 'outputTokens'] as const
+
+/**
+ * A period row's share of a cumulative callback's token counts: the cumulative counts minus what
+ * the request's other rows already hold, so summing the rows never counts a token twice.
+ */
+function periodUsageMetadata(
+ metadata: UsageLogMetadata | undefined,
+ otherRows: readonly { metadata: unknown }[]
+): UsageLogMetadata | undefined {
+ const cumulative = toRecordOrNull(metadata)
+ if (!cumulative || otherRows.length === 0) return metadata
+ const share: Record = { ...cumulative }
+ for (const field of CUMULATIVE_TOKEN_FIELDS) {
+ const total = toNumberOrNull(cumulative[field])
+ if (total === null) continue
+ const recorded = otherRows.reduce(
+ (sum, row) => sum + (toNumberOrNull(toRecordOrNull(row.metadata)?.[field]) ?? 0),
+ 0
+ )
+ share[field] = Math.max(0, total - recorded)
+ }
+ return share
}
export type CumulativeUsageContextField =
@@ -612,6 +682,23 @@ export class CumulativeUsageContextMismatchError extends Error {
}
}
+/**
+ * A cumulative charge whose billing period the payer has already settled: the subscription ended
+ * and its final invoice summed that period. The charge is refused rather than recorded where no
+ * invoice will ever read it.
+ */
+export class CumulativeUsagePeriodClosedError extends Error {
+ constructor(
+ readonly eventKey: string,
+ readonly billingPeriod: { start: Date; end: Date }
+ ) {
+ super(
+ `Cumulative usage event "${eventKey}" targets a billing period that has already been settled`
+ )
+ this.name = 'CumulativeUsagePeriodClosedError'
+ }
+}
+
interface CumulativeUsageLedgerBinding {
userId: string
workspaceId: string | null
@@ -628,6 +715,11 @@ function assertCumulativeUsageLedgerBinding(
workspaceId?: string
billingContext: BillingContext
eventKey: string
+ /**
+ * A request whose first charge landed after its period closed, or after an anchor reset moved
+ * its start forward, is stamped with a later one.
+ */
+ allowLaterPeriod?: boolean
}
): void {
const mismatchedFields: CumulativeUsageContextField[] = []
@@ -643,11 +735,16 @@ function assertCumulativeUsageLedgerBinding(
) {
mismatchedFields.push('billing entity')
}
- if (
- existing.billingPeriodStart?.getTime() !==
- expected.billingContext.billingPeriod.start.getTime() ||
- existing.billingPeriodEnd?.getTime() !== expected.billingContext.billingPeriod.end.getTime()
- ) {
+ const frozenPeriod = expected.billingContext.billingPeriod
+ const samePeriod =
+ existing.billingPeriodStart?.getTime() === frozenPeriod.start.getTime() &&
+ existing.billingPeriodEnd?.getTime() === frozenPeriod.end.getTime()
+ // The same forward-only rule that rolls a charge into a new period row.
+ const laterPeriod =
+ expected.allowLaterPeriod === true &&
+ existing.billingPeriodStart !== null &&
+ existing.billingPeriodStart.getTime() > frozenPeriod.start.getTime()
+ if (!samePeriod && !laterPeriod) {
mismatchedFields.push('billing period')
}
@@ -703,6 +800,7 @@ export async function recordCumulativeUsage(
cost,
eventKey,
metadata,
+ payerSubscriptionId,
} = params
if (workspaceId && (!billingEntity || !billingPeriod)) {
@@ -744,10 +842,12 @@ export async function recordCumulativeUsage(
await acquireAdvisoryXactLock(tx, 'usage_log_event', eventKey)
enterStage('read')
- const [existing] = await tx
+ const rows = await tx
.select({
id: usageLog.id,
+ eventKey: usageLog.eventKey,
cost: usageLog.cost,
+ metadata: usageLog.metadata,
userId: usageLog.userId,
workspaceId: usageLog.workspaceId,
billingEntityType: usageLog.billingEntityType,
@@ -756,55 +856,135 @@ export async function recordCumulativeUsage(
billingPeriodEnd: usageLog.billingPeriodEnd,
})
.from(usageLog)
- .where(eq(usageLog.eventKey, eventKey))
- .limit(1)
-
- if (existing) {
- assertCumulativeUsageLedgerBinding(existing, {
+ .where(
+ payerSubscriptionId
+ ? inArray(
+ usageLog.eventKey,
+ Array.from({ length: MAX_CUMULATIVE_PERIOD_ROWS }, (_, index) =>
+ cumulativePeriodEventKey(eventKey, index)
+ )
+ )
+ : eq(usageLog.eventKey, eventKey)
+ )
+ .limit(MAX_CUMULATIVE_PERIOD_ROWS)
+
+ // Period rows are written in order under this lock, so they are the keys 0..n-1.
+ const chain = payerSubscriptionId
+ ? Array.from({ length: rows.length }, (_, index) =>
+ rows.find((row) => row.eventKey === cumulativePeriodEventKey(eventKey, index))
+ ).filter((row) => row !== undefined)
+ : rows.slice(0, 1)
+ if (payerSubscriptionId && chain.length !== rows.length) {
+ throw new Error(`Cumulative usage event "${eventKey}" has a gap in its period rows`)
+ }
+ const [anchor] = chain
+ if (anchor) {
+ assertCumulativeUsageLedgerBinding(anchor, {
userId,
workspaceId,
billingContext,
eventKey,
+ allowLaterPeriod: Boolean(payerSubscriptionId),
})
}
- const recorded = existing ? Number.parseFloat(existing.cost) : 0
+ const latest = chain.at(-1)
+ const latestPeriod =
+ latest?.billingPeriodStart && latest.billingPeriodEnd
+ ? { start: latest.billingPeriodStart, end: latest.billingPeriodEnd }
+ : billingContext.billingPeriod
+ const recorded = sumLedgerCost(chain)
const { shouldBill, delta, newTotal } = resolveCumulativeTopUp(recorded, cost)
if (!shouldBill) {
enterStage('commit')
- return { billed: false, delta: 0, total: recorded }
+ return { billed: false, delta: 0, total: recorded, billingPeriod: latestPeriod }
+ }
+
+ // The payer's current period and close marker, share-locked so a change to either (a
+ // rollover, an anchor reset inside the old period, or a terminal settlement) waits for this
+ // write to commit, and whatever a close later sums for the old period is final.
+ const [currentPeriod] = payerSubscriptionId
+ ? await tx
+ .select({
+ start: subscriptionTable.periodStart,
+ end: subscriptionTable.periodEnd,
+ closedThrough: subscriptionTable.lastClosedPeriodStart,
+ })
+ .from(subscriptionTable)
+ .where(eq(subscriptionTable.id, payerSubscriptionId))
+ .for('share')
+ .limit(1)
+ : []
+
+ // Only ever forward: a subscription period that does not start after the latest row's
+ // keeps topping up that row, whatever the wall clock or a replayed webhook says. A start
+ // that moved forward inside the old period (anchor reset, resync) still rolls, so the old
+ // period's close is never topped up after the fact.
+ const rolledPeriod =
+ currentPeriod?.start &&
+ currentPeriod.end &&
+ currentPeriod.start.getTime() > latestPeriod.start.getTime()
+ ? { start: currentPeriod.start, end: currentPeriod.end }
+ : null
+ if (rolledPeriod && latest && chain.length >= MAX_CUMULATIVE_PERIOD_ROWS) {
+ throw new Error(`Cumulative usage event "${eventKey}" spans too many billing periods`)
+ }
+ // A marker at or past the target period's end means that period is already settled — a
+ // terminal settlement marks it whatever the subscription's bounds — so nothing would
+ // ever invoice this charge.
+ const targetPeriod = rolledPeriod ?? latestPeriod
+ if (
+ currentPeriod?.closedThrough &&
+ currentPeriod.closedThrough.getTime() >= targetPeriod.end.getTime()
+ ) {
+ throw new CumulativeUsagePeriodClosedError(eventKey, targetPeriod)
}
enterStage('write')
- if (existing) {
+ if (latest && !rolledPeriod) {
+ const otherRows = chain.slice(0, -1)
+ const latestCost =
+ otherRows.length === 0
+ ? newTotal
+ : Number((newTotal - sumLedgerCost(otherRows)).toFixed(PERIOD_COST_DECIMALS))
await tx
.update(usageLog)
- .set({ cost: newTotal.toString(), metadata: metadata ?? null })
- .where(eq(usageLog.id, existing.id))
- } else {
- await recordUsage({
- userId,
- workspaceId,
- tx,
- billingEntity: billingContext.billingEntity,
- billingPeriod: billingContext.billingPeriod,
- entries: [
- {
- category: 'model',
- source,
- description: model,
- cost: newTotal,
- eventKey,
- sourceReference: eventKey,
- ...(metadata ? { metadata } : {}),
- },
- ],
- })
+ .set({
+ cost: latestCost.toString(),
+ metadata: periodUsageMetadata(metadata, otherRows) ?? null,
+ })
+ .where(eq(usageLog.id, latest.id))
+ enterStage('commit')
+ return { billed: true, delta, total: newTotal, billingPeriod: latestPeriod }
}
+ const rowMetadata = periodUsageMetadata(metadata, chain)
+ await recordUsage({
+ userId,
+ workspaceId,
+ tx,
+ billingEntity: billingContext.billingEntity,
+ billingPeriod: targetPeriod,
+ entries: [
+ {
+ category: 'model',
+ source,
+ description: model,
+ cost: chain.length === 0 ? newTotal : Number(delta.toFixed(PERIOD_COST_DECIMALS)),
+ eventKey: cumulativePeriodEventKey(eventKey, chain.length),
+ sourceReference: eventKey,
+ ...(rowMetadata ? { metadata: rowMetadata } : {}),
+ },
+ ],
+ })
enterStage('commit')
- return { billed: true, delta, total: newTotal }
+ return {
+ billed: true,
+ delta,
+ total: newTotal,
+ billingPeriod: { start: targetPeriod.start, end: targetPeriod.end },
+ }
})
succeeded = true
return result
diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts
index 9040e5084c1..952b3cd30a9 100644
--- a/apps/sim/lib/billing/cycle-close.ts
+++ b/apps/sim/lib/billing/cycle-close.ts
@@ -191,12 +191,16 @@ export async function closeElapsedPeriodBeforeDeletion(subscriptionId: string):
* Claim the terminal period for a subscription that is being deleted, BEFORE
* the deletion handler computes and charges final overage. Reads the
* subscription row fresh (webhook payloads can be stale across a rollover)
- * and advances the close marker to its current `periodStart` in one
- * transaction, serializing with the sweep on the subscription row: an
- * in-flight sweep close then fails its guarded marker claim and rolls back —
- * including its outbox invoice — so deletion and sweep can never both bill
- * the same period. Call `closeElapsedPeriodBeforeDeletion` first so a lagging
- * elapsed period is settled rather than jumped. Returns the fresh period
+ * and, in one transaction, advances the close marker to the terminal period's end:
+ * the period is settled from here on, so a cost callback that commits after
+ * this claim is refused rather than topping up a period the final invoice has
+ * already summed (`recordCumulativeUsage` reads the marker under a share lock
+ * on the same row, so every charge either commits before this claim or sees
+ * the marker). This also serializes with the sweep on the subscription row:
+ * an in-flight sweep close then fails its guarded marker claim and rolls
+ * back — including its outbox invoice — so deletion and sweep can never both
+ * bill the same period. Call `closeElapsedPeriodBeforeDeletion` first so a
+ * lagging elapsed period is settled rather than jumped. Returns the period
* bounds for the deletion flow to settle against, plus `markerWasCurrent`:
* whether the close marker had already caught up to the terminal period.
* The `billedOverageThisPeriod` tracker only ever holds collections for the
@@ -241,7 +245,10 @@ export async function claimTerminalPeriod(
const markerWasCurrent =
!!row.lastClosedPeriodStart &&
row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime()
- if (!markerWasCurrent && options.sealLagging) {
+ if (!markerWasCurrent && !options.sealLagging) {
+ return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent }
+ }
+ if (!markerWasCurrent) {
logger.error(
'Sealing an unclosed elapsed period at terminal claim; residual overage forgiven',
{
@@ -250,8 +257,8 @@ export async function claimTerminalPeriod(
periodStart: row.periodStart.toISOString(),
}
)
- await claimCloseMarker(tx, subscriptionId, row.periodStart)
}
+ await claimCloseMarker(tx, subscriptionId, row.periodEnd ?? row.periodStart)
return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent }
})
}
diff --git a/apps/sim/lib/billing/usage-upgrade.ts b/apps/sim/lib/billing/usage-upgrade.ts
new file mode 100644
index 00000000000..ceee5e1fe9e
--- /dev/null
+++ b/apps/sim/lib/billing/usage-upgrade.ts
@@ -0,0 +1,74 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { UsageUpgradePayload } from '@/lib/api/contracts/subscription'
+import type { AttributedUsageLimitsResult } from '@/lib/billing/core/billing-attribution'
+import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
+import type { BillingEntity } from '@/lib/billing/core/usage-log'
+import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
+import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
+
+const logger = createLogger('UsageUpgrade')
+
+const UPGRADE_PLAN_MESSAGE =
+ "You've reached your usage limit. Please upgrade your plan to continue."
+
+const MEMBER_CAP_MESSAGE =
+ "You've reached the usage limit your organization set for you this billing period. Only an organization owner or admin can raise it — please ask them to continue."
+
+/**
+ * The payer a run is billed to, as the upgrade card needs it: its billing entity and its
+ * subscription's plan. An attribution snapshot is one; a direct-v1 run's mid-run verdict carries one.
+ */
+export interface UsageUpgradePayer {
+ readonly billingEntity: Readonly
+ readonly payerSubscription: { readonly plan: string } | null
+}
+
+/**
+ * The upgrade card for a payer over its usage limit: a plan upgrade for a free payer, a limit
+ * increase for a paid one, with copy naming who can raise an organization's limit. A member
+ * over the cap their organization set gets copy naming who can raise that cap. A known payer
+ * decides the card without a query; otherwise the actor's current subscription decides, and a
+ * lookup that fails falls back to the plan-upgrade card.
+ */
+export async function resolveUsageUpgradePayload(
+ userId: string,
+ payer?: UsageUpgradePayer,
+ scope?: AttributedUsageLimitsResult['scope']
+): Promise {
+ if (scope === 'member') {
+ return { reason: 'usage_limit', action: 'increase_limit', message: MEMBER_CAP_MESSAGE }
+ }
+ let plan: string | undefined
+ let orgScoped = false
+ try {
+ if (payer) {
+ plan = payer.payerSubscription?.plan
+ orgScoped = payer.billingEntity.type === 'organization'
+ } else {
+ const subscription = await getHighestPrioritySubscription(userId)
+ plan = subscription?.plan
+ orgScoped = isOrgScopedSubscription(subscription, userId)
+ }
+ } catch (error) {
+ logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan', {
+ error: getErrorMessage(error),
+ })
+ }
+
+ if (!plan || !isPaid(plan)) {
+ return { reason: 'usage_limit', action: 'upgrade_plan', message: UPGRADE_PLAN_MESSAGE }
+ }
+ // Paid plans get `increase_limit`; the copy says who can raise it when the user cannot.
+ const message = !orgScoped
+ ? "You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue."
+ : isEnterprise(plan)
+ ? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue."
+ : "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings."
+ return { reason: 'usage_limit', action: 'increase_limit', message }
+}
+
+/** The assistant text that renders {@link payload} as the usage card. */
+export function formatUsageUpgradeTag(payload: UsageUpgradePayload): string {
+ return `${JSON.stringify(payload)} `
+}
diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts
index f6528ca3906..6f6badb33c9 100644
--- a/apps/sim/lib/billing/webhooks/subscription.ts
+++ b/apps/sim/lib/billing/webhooks/subscription.ts
@@ -294,7 +294,9 @@ export async function handleSubscriptionDeleted(
// Then claim the terminal period BEFORE computing or charging: this
// reads the row's fresh period (webhook payloads can be stale across
- // a rollover) and serializes with the cycle-close sweep. A lagging
+ // a rollover), serializes with the cycle-close sweep, and marks the
+ // terminal period settled so a still-running request's later charge
+ // is refused instead of landing after the final invoice. A lagging
// marker here means the close above deferred OR a rollover committed
// in between — run the close once more (it settles a freshly elapsed
// period; a deferred close defers again, loudly), then seal so the
diff --git a/apps/sim/lib/charts/bar-row-highlight.test.ts b/apps/sim/lib/charts/bar-row-highlight.test.ts
new file mode 100644
index 00000000000..91797344291
--- /dev/null
+++ b/apps/sim/lib/charts/bar-row-highlight.test.ts
@@ -0,0 +1,63 @@
+/** @vitest-environment jsdom */
+
+import { init } from 'echarts'
+import { expect, it } from 'vitest'
+import { installBarRowHighlight } from '@/lib/charts/bar-row-highlight'
+import { buildChartRenderOption, horizontalBarChartHeight } from '@/lib/charts/option'
+import { applyChartTooltipDefaults } from '@/lib/charts/theme'
+
+it('highlights the hovered row around its label and bar without touching neighbouring rows', () => {
+ const categories = Array.from({ length: 10 }, (_, index) => `sim-alarm-${index}`)
+ const spec = {
+ animation: false,
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category', inverse: true, data: categories },
+ series: [{ type: 'bar', data: categories.map((_, index) => 100 - index * 9) }],
+ }
+ const option = applyChartTooltipDefaults(buildChartRenderOption({ option: spec }))
+ const height = horizontalBarChartHeight(spec, categories.length) ?? 0
+ const element = document.createElement('div')
+ element.style.setProperty('--text-body', '#111111')
+ document.body.append(element)
+ const chart = init(element, undefined, { renderer: 'svg', width: 720, height })
+ try {
+ chart.setOption(option)
+ const dispose = installBarRowHighlight(chart, option)
+ const hovered = 4
+ chart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: hovered })
+ const elements = chart.getZr().storage.getDisplayList(true)
+ const labelTop = (text: string) => {
+ const label = elements.find((node) => node.type === 'tspan' && node.style.text === text)
+ if (!label) throw new Error(`Missing label ${text}`)
+ const rect = label.getBoundingRect().clone()
+ if (label.transform) rect.applyTransform(label.transform)
+ return rect.y
+ }
+ const highlight = elements.find(
+ (node) => node.type === 'rect' && !node.invisible && node.style.opacity === 0.06
+ )
+ if (!highlight) throw new Error('Missing row highlight')
+ const area = highlight.getBoundingRect()
+ const barCenter = (row: number) => chart.convertToPixel({ yAxisIndex: 0 }, row)
+ expect(area.y).toBeLessThanOrEqual(labelTop(categories[hovered]))
+ expect(area.y + area.height).toBeGreaterThanOrEqual(barCenter(hovered) + 8)
+ expect(area.y).toBeGreaterThanOrEqual(barCenter(hovered - 1) + 8)
+ expect(area.y + area.height).toBeLessThanOrEqual(labelTop(categories[hovered + 1]))
+
+ chart.resize({ width: 720, height: height * 2 })
+ chart.getZr().flush()
+ chart.getZr().flush()
+ const resized = chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .find((node) => node.type === 'rect' && !node.invisible && node.style.opacity === 0.06)
+ if (!resized) throw new Error('Missing row highlight after resize')
+ const moved = resized.getBoundingRect()
+ expect(moved.y + moved.height).toBeGreaterThanOrEqual(barCenter(hovered) + 8)
+ expect(moved.y).toBeGreaterThanOrEqual(barCenter(hovered - 1) + 8)
+ dispose()
+ } finally {
+ chart.dispose()
+ element.remove()
+ }
+})
diff --git a/apps/sim/lib/charts/bar-row-highlight.ts b/apps/sim/lib/charts/bar-row-highlight.ts
new file mode 100644
index 00000000000..8e2a98d1dcd
--- /dev/null
+++ b/apps/sim/lib/charts/bar-row-highlight.ts
@@ -0,0 +1,89 @@
+import { toRecord } from '@sim/utils/object'
+import type { EChartsType } from 'echarts'
+import {
+ ABOVE_BAR_LABEL_SPACE,
+ horizontalBarWidth,
+ isAboveBarLabelLayout,
+} from '@/lib/charts/option'
+
+const HIGHLIGHT_ID = 'sim-bar-row-highlight'
+/** Space kept below the bar inside the highlight; the rest of the row sits above it. */
+const BELOW_BAR_MARGIN = 2
+
+/**
+ * ECharts centres its shadow pointer on the bar, so with labels above the bars it cuts through
+ * the label and spills into the next row. This draws one row highlight around label and bar.
+ */
+export function installBarRowHighlight(
+ chart: EChartsType,
+ option: Record
+): () => void {
+ if (!isAboveBarLabelLayout(option)) return () => {}
+ const barWidth = horizontalBarWidth(option)
+ const rowHeight = barWidth + ABOVE_BAR_LABEL_SPACE
+ const grid = toRecord(Array.isArray(option.grid) ? option.grid[0] : option.grid)
+ const inset = (value: unknown) => {
+ if (typeof value === 'number') return value
+ if (typeof value === 'string' && value.endsWith('%'))
+ return (chart.getWidth() * Number.parseFloat(value)) / 100
+ return typeof value === 'string' && Number.isFinite(Number(value)) ? Number(value) : 0
+ }
+ const color = getComputedStyle(chart.getDom()).getPropertyValue('--text-body').trim()
+ let current: number | null = null
+ /** Geometry last drawn; a resize changes it for the same row, so rows alone cannot dedupe. */
+ let drawn = ''
+
+ const render = (row: number | null) => {
+ current = row
+ if (row === null) {
+ if (drawn === 'hidden') return
+ drawn = 'hidden'
+ chart.setOption({ graphic: [{ id: HIGHLIGHT_ID, type: 'rect', invisible: true }] })
+ return
+ }
+ const center = chart.convertToPixel({ yAxisIndex: 0 }, row)
+ const left = inset(grid.left)
+ const shape = {
+ x: left,
+ y: center + barWidth / 2 + BELOW_BAR_MARGIN - rowHeight,
+ width: chart.getWidth() - left - inset(grid.right),
+ height: rowHeight,
+ }
+ const geometry = JSON.stringify(shape)
+ if (geometry === drawn) return
+ drawn = geometry
+ chart.setOption({
+ graphic: [
+ {
+ id: HIGHLIGHT_ID,
+ type: 'rect',
+ invisible: false,
+ silent: true,
+ z: 0,
+ shape,
+ style: { fill: color, opacity: 0.06 },
+ },
+ ],
+ })
+ }
+ const onPointer = (event: unknown) => {
+ const axes = toRecord(event).axesInfo
+ const category = Array.isArray(axes)
+ ? axes.map(toRecord).find((axis) => axis.axisDim === 'y')
+ : undefined
+ render(typeof category?.value === 'number' ? category.value : null)
+ }
+ const onLeave = () => render(null)
+ /** Resizes re-render the chart; redraw the active row so it follows the new layout. */
+ const onRendered = () => {
+ if (current !== null) render(current)
+ }
+ chart.on('updateAxisPointer', onPointer)
+ chart.on('globalout', onLeave)
+ chart.on('finished', onRendered)
+ return () => {
+ chart.off('updateAxisPointer', onPointer)
+ chart.off('globalout', onLeave)
+ chart.off('finished', onRendered)
+ }
+}
diff --git a/apps/sim/lib/charts/option.test.ts b/apps/sim/lib/charts/option.test.ts
new file mode 100644
index 00000000000..c6a198e9bc4
--- /dev/null
+++ b/apps/sim/lib/charts/option.test.ts
@@ -0,0 +1,303 @@
+import { init } from 'echarts'
+import { describe, expect, it } from 'vitest'
+import {
+ buildChartRenderOption,
+ CHART_BAR_MAX_WIDTH,
+ horizontalBarChartHeight,
+ isAboveBarLabelLayout,
+} from '@/lib/charts/option'
+
+describe('chart dataset injection', () => {
+ it('injects empty results, ahead of authored datasets, without mutation', () => {
+ const authored = { dataset: { source: [{ count: 999 }] } }
+ expect(buildChartRenderOption({ option: authored, rows: [] }).dataset).toEqual([
+ { id: 'table', source: [] },
+ authored.dataset,
+ ])
+ expect(authored).toEqual({ dataset: { source: [{ count: 999 }] } })
+ })
+ it('preserves static options without query data', () => {
+ expect(buildChartRenderOption({ option: { dataset: { source: [1, 2] } } }).dataset).toEqual({
+ source: [1, 2],
+ })
+ })
+})
+
+describe('horizontal bar label layout', () => {
+ it('moves category labels above the plot rows without mutating authored options', () => {
+ const authored = {
+ xAxis: { type: 'value' },
+ yAxis: [{ type: 'category', inverse: true }],
+ series: [{ type: 'bar', encode: { x: 'count', y: 'category' } }],
+ }
+ const result = buildChartRenderOption({ option: authored })
+ expect(result.yAxis).toEqual([
+ expect.objectContaining({
+ inverse: true,
+ axisLabel: expect.objectContaining({
+ inside: true,
+ align: 'left',
+ verticalAlign: 'bottom',
+ }),
+ }),
+ ])
+ expect(authored.yAxis[0]).not.toHaveProperty('axisLabel')
+ })
+
+ it('reserves the authored gap between grouped bars in each row', () => {
+ const grouped = (barGap?: string | number) => ({
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category' },
+ series: [
+ { type: 'bar', barWidth: 20, ...(barGap === undefined ? {} : { barGap }) },
+ { type: 'bar', barWidth: 20 },
+ ],
+ })
+ const rows = 10
+ const base = horizontalBarChartHeight(grouped('0%'), rows) ?? 0
+ expect(horizontalBarChartHeight(grouped(), rows)).toBe(base + rows * 4)
+ expect(horizontalBarChartHeight(grouped('150%'), rows)).toBe(base + rows * 30)
+ expect(horizontalBarChartHeight(grouped(40), rows)).toBe(base + rows * 40)
+ expect(horizontalBarChartHeight(grouped('-100%'), rows)).toBe(base - rows * 20)
+ })
+
+ it('keeps every authored tooltip entry when turning off the shadow pointer', () => {
+ const result = buildChartRenderOption({
+ option: {
+ tooltip: [{ show: true, confine: true }],
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category' },
+ series: [{ type: 'bar' }],
+ },
+ })
+ expect(result.tooltip).toEqual([{ show: true, confine: true, axisPointer: { type: 'none' } }])
+ expect(isAboveBarLabelLayout(result)).toBe(true)
+ })
+
+ it('keeps the label column for grouped bars and sizes rows for every bar in the group', () => {
+ const grouped = {
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category' },
+ series: [{ type: 'bar' }, { type: 'bar' }],
+ }
+ expect(buildChartRenderOption({ option: grouped }).yAxis).not.toHaveProperty('axisLabel')
+ const single = { ...grouped, series: [{ type: 'bar' }] }
+ const rows = 20
+ expect(horizontalBarChartHeight(grouped, rows)).toBeGreaterThanOrEqual(
+ (horizontalBarChartHeight({ ...single, grid: { left: 0 } }, rows) ?? 0) +
+ rows * CHART_BAR_MAX_WIDTH
+ )
+ const stacked = {
+ ...grouped,
+ series: [
+ { type: 'bar', stack: 'total' },
+ { type: 'bar', stack: 'total' },
+ ],
+ }
+ expect(buildChartRenderOption({ option: stacked }).yAxis).toMatchObject({
+ axisLabel: { inside: true },
+ })
+ })
+
+ it('keeps the ECharts label column for percentage bar widths it cannot size per row', () => {
+ const option = {
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category' },
+ series: [{ type: 'bar', barWidth: '60%' }],
+ }
+ const result = buildChartRenderOption({ option })
+ expect(result.yAxis).not.toHaveProperty('axisLabel')
+ expect(horizontalBarChartHeight(option, 10)).toBe(
+ horizontalBarChartHeight({ ...option, grid: { left: 0 } }, 10)
+ )
+ })
+
+ it('preserves authored category label placement and leaves vertical bars alone', () => {
+ const axisLabel = { inside: false, align: 'right', margin: 12, padding: 0 }
+ const result = buildChartRenderOption({
+ option: {
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category', axisLabel },
+ series: [{ type: 'bar' }],
+ },
+ })
+ expect(result.yAxis).toMatchObject({ axisLabel })
+ const vertical = {
+ xAxis: { type: 'category' },
+ yAxis: { type: 'value' },
+ series: [{ type: 'bar' }],
+ }
+ expect(buildChartRenderOption({ option: vertical }).yAxis).toEqual(vertical.yAxis)
+ })
+})
+
+describe('rendered chart bounds', () => {
+ it('keeps percentage end ticks inside the canvas and labels clear of thick bars', () => {
+ const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 360, height: 240 })
+ const categories = ['Local delivery cooperative', 'Northstar Express', 'Parcelway']
+ try {
+ chart.setOption(
+ buildChartRenderOption({
+ option: {
+ animation: false,
+ xAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
+ yAxis: { type: 'category', inverse: true, data: categories },
+ series: [{ type: 'bar', barWidth: 28, data: [84, 96, 81] }],
+ },
+ })
+ )
+ const labels = chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .filter((element) => element.type === 'tspan')
+ .map((element) => {
+ const bounds = element.getBoundingRect().clone()
+ if (element.transform) bounds.applyTransform(element.transform)
+ return { text: element.style.text, bounds }
+ })
+ expect(labels.some((label) => label.text === '100%')).toBe(true)
+ for (const { text, bounds } of labels) {
+ expect(bounds.x, String(text)).toBeGreaterThanOrEqual(0)
+ expect(bounds.x + bounds.width, String(text)).toBeLessThanOrEqual(360)
+ expect(bounds.y, String(text)).toBeGreaterThanOrEqual(0)
+ expect(bounds.y + bounds.height, String(text)).toBeLessThanOrEqual(240)
+ const categoryIndex = categories.indexOf(String(text))
+ if (categoryIndex === -1) continue
+ const center = chart.convertToPixel({ seriesIndex: 0 }, [0, categoryIndex])
+ if (!Array.isArray(center)) throw new Error('Expected a Cartesian coordinate')
+ expect(bounds.y + bounds.height).toBeLessThanOrEqual(center[1] - 14 - 6)
+ }
+ } finally {
+ chart.dispose()
+ }
+ })
+
+ it('sizes horizontal bars so every above-bar label clears the neighbouring bars', () => {
+ const categories = Array.from(
+ { length: 10 },
+ (_, index) => `sim-production-us-east-1-alarm-number-${index}`
+ )
+ const option = {
+ animation: false,
+ xAxis: { type: 'value', name: 'Investigations' },
+ yAxis: { type: 'category', inverse: true, data: categories },
+ series: [{ type: 'bar', data: categories.map((_, index) => 100 - index * 9) }],
+ }
+ const height = horizontalBarChartHeight(option, categories.length)
+ const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 720, height })
+ try {
+ chart.setOption(buildChartRenderOption({ option }))
+ const labels = chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .filter((element) => element.type === 'tspan')
+ .map((element) => {
+ const bounds = element.getBoundingRect().clone()
+ if (element.transform) bounds.applyTransform(element.transform)
+ return { text: String(element.style.text), bounds }
+ })
+ categories.forEach((category, index) => {
+ const label = labels.find(({ text }) => text === category)
+ if (!label) throw new Error(`Missing label for ${category}`)
+ const center = chart.convertToPixel({ seriesIndex: 0 }, [0, index])
+ if (!Array.isArray(center)) throw new Error('Expected a Cartesian coordinate')
+ expect(label.bounds.y + label.bounds.height, category).toBeLessThanOrEqual(
+ center[1] - CHART_BAR_MAX_WIDTH / 2
+ )
+ if (index === 0) return
+ const previous = chart.convertToPixel({ seriesIndex: 0 }, [0, index - 1])
+ if (!Array.isArray(previous)) throw new Error('Expected a Cartesian coordinate')
+ expect(label.bounds.y, category).toBeGreaterThanOrEqual(
+ previous[1] + CHART_BAR_MAX_WIDTH / 2
+ )
+ })
+ expect(horizontalBarChartHeight({ xAxis: { type: 'category' } }, 10)).toBeNull()
+ } finally {
+ chart.dispose()
+ }
+ })
+
+ it('keeps an authored left label column intact instead of blending it with inside labels', () => {
+ const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 720, height: 360 })
+ const rows = [
+ { alarm: 'sim-staging-us-east-1-integ-failure', investigations: 120 },
+ { alarm: 'sim-production-us-east-1-copilot-5xx-rate', investigations: 64 },
+ { alarm: 'trigger-dev-queue-depth', investigations: 9 },
+ ]
+ try {
+ chart.setOption(
+ buildChartRenderOption({
+ rows,
+ option: {
+ animation: false,
+ grid: { containLabel: true, left: 12, right: 45, top: 15, bottom: 25 },
+ xAxis: { type: 'value', name: 'Investigations', min: 0, minInterval: 1 },
+ yAxis: {
+ type: 'category',
+ inverse: true,
+ axisLabel: { width: 320, overflow: 'truncate', fontSize: 11 },
+ },
+ series: [
+ {
+ type: 'bar',
+ label: { show: true, position: 'right' },
+ encode: { x: 'investigations', y: 'alarm' },
+ },
+ ],
+ },
+ })
+ )
+ const labels = chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .filter((element) => element.type === 'tspan')
+ .map((element) => {
+ const bounds = element.getBoundingRect().clone()
+ if (element.transform) bounds.applyTransform(element.transform)
+ return { text: String(element.style.text), bounds }
+ })
+ rows.forEach(({ alarm }, index) => {
+ const label = labels.find(({ text }) => alarm.startsWith(text.replace(/…$/, '')))
+ if (!label) throw new Error(`Missing label for ${alarm}`)
+ const origin = chart.convertToPixel({ seriesIndex: 0 }, [0, index])
+ if (!Array.isArray(origin)) throw new Error('Expected a Cartesian coordinate')
+ expect(label.bounds.x, alarm).toBeGreaterThanOrEqual(0)
+ expect(label.bounds.y, alarm).toBeGreaterThanOrEqual(0)
+ expect(label.bounds.x + label.bounds.width, alarm).toBeLessThanOrEqual(origin[0])
+ })
+ } finally {
+ chart.dispose()
+ }
+ })
+
+ it('fits axis names below a legend even when the authored grid starts near the top', () => {
+ const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 320, height: 240 })
+ try {
+ chart.setOption(
+ buildChartRenderOption({
+ option: {
+ animation: false,
+ legend: {},
+ grid: { left: 58, right: 20, top: 24, bottom: 58 },
+ xAxis: { type: 'category', data: ['Direct', 'Partners'] },
+ yAxis: { type: 'value', name: 'USD' },
+ series: [{ name: 'Net sales', type: 'bar', data: [14000, 16000] }],
+ },
+ })
+ )
+ const name = chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .find((element) => element.type === 'tspan' && element.style.text === 'USD')
+ expect(name).toBeDefined()
+ if (!name) throw new Error('Missing axis name')
+ const bounds = name.getBoundingRect().clone()
+ if (name.transform) bounds.applyTransform(name.transform)
+ expect(bounds.y).toBeGreaterThanOrEqual(48)
+ expect(bounds.x).toBeGreaterThanOrEqual(0)
+ expect(bounds.x + bounds.width).toBeLessThanOrEqual(320)
+ } finally {
+ chart.dispose()
+ }
+ })
+})
diff --git a/apps/sim/lib/charts/option.ts b/apps/sim/lib/charts/option.ts
index d862a89291e..ea19c14df40 100644
--- a/apps/sim/lib/charts/option.ts
+++ b/apps/sim/lib/charts/option.ts
@@ -9,19 +9,149 @@
* between slide chrome and slide content.
*/
+import { toRecord } from '@sim/utils/object'
+
+export const CHART_BAR_MAX_WIDTH = 16
+
export interface ChartRenderInput {
title?: string
option: Record
rows?: Array> | null
}
+/** Category labels share the plot width for a single horizontal Cartesian bar chart. */
+export function isHorizontalBarOption(option: Record): boolean {
+ const yAxes = Array.isArray(option.yAxis) ? option.yAxis : [option.yAxis]
+ const xAxes = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis]
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ return (
+ yAxes.length === 1 &&
+ xAxes.length === 1 &&
+ toRecord(yAxes[0]).type === 'category' &&
+ toRecord(xAxes[0]).type === 'value' &&
+ series.length > 0 &&
+ series.every((entry) => toRecord(entry).type === 'bar')
+ )
+}
+
+/** Applies `update` to every tooltip entry, keeping ECharts' array form when authored. */
+export function mapTooltipEntries(
+ tooltip: unknown,
+ update: (entry: Record) => Record
+): Record | Record[] {
+ return Array.isArray(tooltip)
+ ? tooltip.map((entry) => update(toRecord(entry)))
+ : update(toRecord(tooltip))
+}
+
+const CATEGORY_LABEL_LAYOUT_KEYS = ['inside', 'width', 'margin'] as const
+
+/** ECharts' default `barGap`: the space between grouped bars, relative to bar width. */
+const DEFAULT_BAR_GAP = '20%'
+
+/**
+ * Pixel gap between side-by-side bars. ECharts reads `barGap` from the last series that sets
+ * it: a number is pixels, a percentage is relative to the bar width, and a negative gap
+ * overlaps the bars, which then need no extra space.
+ */
+function barGapPixels(option: Record, barWidth: number): number {
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ let gap: unknown = DEFAULT_BAR_GAP
+ for (const entry of series) {
+ const barGap = toRecord(entry).barGap
+ if (barGap !== undefined) gap = barGap
+ }
+ const pixels =
+ typeof gap === 'number'
+ ? gap
+ : typeof gap === 'string' && gap.endsWith('%')
+ ? (barWidth * Number.parseFloat(gap)) / 100
+ : Number(gap)
+ return Number.isFinite(pixels) ? pixels : 0
+}
+
+/**
+ * Bar thickness per slot in one category row: stacked series share a slot, every other series
+ * gets its own, and slots sit side by side within the row.
+ */
+function barSlotWidths(option: Record): number[] {
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ const slots = new Map()
+ series.forEach((entry, index) => {
+ const bar = toRecord(entry)
+ const width = bar.barWidth ?? bar.barMaxWidth
+ const key = bar.stack ?? Symbol(index)
+ slots.set(
+ key,
+ Math.max(slots.get(key) ?? 0, typeof width === 'number' ? width : CHART_BAR_MAX_WIDTH)
+ )
+ })
+ return [...slots.values()]
+}
+
+/**
+ * Labels above the bars are a default layout, not a blend: an option that places its own
+ * category labels or reserves a left inset keeps the standard ECharts left column intact. So
+ * does a percentage bar width, which scales with the plot and cannot be sized per row, and a
+ * grouped chart, whose side-by-side bars leave no single bar to place a label above.
+ */
+function authorsCategoryLabelColumn(option: Record): boolean {
+ const axis = toRecord(Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis)
+ const axisLabel = toRecord(axis.axisLabel)
+ const grids = Array.isArray(option.grid) ? option.grid : [option.grid]
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ return (
+ barSlotWidths(option).length > 1 ||
+ CATEGORY_LABEL_LAYOUT_KEYS.some((key) => axisLabel[key] !== undefined) ||
+ series.some((entry) => {
+ const bar = toRecord(entry)
+ return [bar.barWidth, bar.barMaxWidth].some(
+ (width) => width !== undefined && typeof width !== 'number'
+ )
+ }) ||
+ grids.some((grid) => {
+ const record = toRecord(grid)
+ return record.left !== undefined || record.containLabel !== undefined
+ })
+ )
+}
+
+export function horizontalBarWidth(option: Record): number {
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ return Math.max(
+ CHART_BAR_MAX_WIDTH,
+ ...series.map((entry) => {
+ const bar = toRecord(entry)
+ const width = bar.barWidth ?? bar.barMaxWidth
+ return typeof width === 'number' ? width : CHART_BAR_MAX_WIDTH
+ })
+ )
+}
+
export function buildChartRenderOption({
title,
option: specOption,
rows,
}: ChartRenderInput): Record {
const option = structuredClone(specOption)
- if (rows && rows.length > 0) {
+ const horizontalBars = isHorizontalBarOption(option) && !authorsCategoryLabelColumn(option)
+ if (horizontalBars) {
+ const barWidth = horizontalBarWidth(option)
+ const axis = toRecord(Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis)
+ axis.axisLabel = {
+ inside: true,
+ align: 'left',
+ verticalAlign: 'bottom',
+ margin: 0,
+ padding: [0, 0, barWidth / 2 + 8, 0],
+ ...toRecord(axis.axisLabel),
+ }
+ option.tooltip = mapTooltipEntries(option.tooltip, (tooltip) => ({
+ ...tooltip,
+ axisPointer: { type: 'none', ...toRecord(tooltip.axisPointer) },
+ }))
+ }
+ if (rows !== null && rows !== undefined) {
// The resolved rows become the FIRST dataset (id "table", datasetIndex 0).
// Spec-declared datasets follow it, so filter/sort transform datasets can
// derive from the injected rows (transforms default to fromDatasetIndex 0,
@@ -68,19 +198,60 @@ export function buildChartRenderOption({
if (l.type === undefined) l.type = 'scroll'
}
}
- // Reserve a chrome row above the plot. Fill only what the spec left unset
- // inside grid — axis-name insets remain the spec's call.
- const chromeTop = hasTitle || hasLegend ? 48 : 16
+ /** ECharts 6 outer bounds fit both end ticks and axis names; containLabel omits names. */
+ const chromeTop = hasTitle || hasLegend ? 48 : horizontalBars ? 24 : 16
+ const gridDefaults = {
+ top: chromeTop,
+ left: 12,
+ right: 12,
+ bottom: 12,
+ outerBounds: { top: chromeTop, left: 12, right: 12, bottom: 12 },
+ outerBoundsContain: 'all',
+ }
if (option.grid === undefined) {
- option.grid = { top: chromeTop, left: 12, right: 12, bottom: 12, containLabel: true }
- } else if (
- option.grid !== null &&
- typeof option.grid === 'object' &&
- !Array.isArray(option.grid)
- ) {
- const g = option.grid as Record
- if (g.top === undefined) g.top = chromeTop
- if (g.containLabel === undefined) g.containLabel = true
+ option.grid = gridDefaults
+ } else if (Array.isArray(option.grid)) {
+ option.grid = option.grid.map((grid) => ({ ...gridDefaults, ...toRecord(grid) }))
+ } else if (option.grid !== null && typeof option.grid === 'object') {
+ option.grid = { ...gridDefaults, ...option.grid }
}
return option
}
+
+/** Label line, its padding above the bar, and the gap before the next row's bar. */
+export const ABOVE_BAR_LABEL_SPACE = 28
+const LEFT_LABEL_ROW_GAP = 12
+const HORIZONTAL_BAR_CHROME_HEIGHT = 72
+const MIN_CHART_HEIGHT = 240
+
+/**
+ * Horizontal bar charts grow with their rows: each row must fit its bar plus, in the
+ * above-bar layout, the category label and a gap before the next bar. Null for other charts.
+ */
+export function horizontalBarChartHeight(
+ option: Record,
+ rowCount: number
+): number | null {
+ if (!isHorizontalBarOption(option)) return null
+ const slots = barSlotWidths(option)
+ const gap = barGapPixels(option, Math.max(...slots))
+ const barsHeight = Math.max(
+ Math.max(...slots),
+ slots.reduce((total, width) => total + width, 0) + gap * (slots.length - 1)
+ )
+ const rowHeight =
+ barsHeight + (authorsCategoryLabelColumn(option) ? LEFT_LABEL_ROW_GAP : ABOVE_BAR_LABEL_SPACE)
+ return Math.max(MIN_CHART_HEIGHT, HORIZONTAL_BAR_CHROME_HEIGHT + rowCount * rowHeight)
+}
+
+/** Rendered options using the above-bar label layout, whose row highlight Sim draws itself. */
+export function isAboveBarLabelLayout(option: Record): boolean {
+ if (!isHorizontalBarOption(option)) return false
+ const axis = toRecord(Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis)
+ const axisLabel = toRecord(axis.axisLabel)
+ const tooltip = Array.isArray(option.tooltip) ? option.tooltip[0] : option.tooltip
+ const pointer = toRecord(toRecord(tooltip).axisPointer)
+ return (
+ axisLabel.inside === true && axisLabel.verticalAlign === 'bottom' && pointer.type === 'none'
+ )
+}
diff --git a/apps/sim/lib/charts/summary.test.ts b/apps/sim/lib/charts/summary.test.ts
new file mode 100644
index 00000000000..8fabac411a9
--- /dev/null
+++ b/apps/sim/lib/charts/summary.test.ts
@@ -0,0 +1,161 @@
+/** @vitest-environment jsdom */
+import * as echarts from 'echarts'
+import { describe, expect, it } from 'vitest'
+import {
+ chartSummaryExtension,
+ formatChartValue,
+ observeChartSummary,
+ summarizeChart,
+} from '@/lib/charts/summary'
+import { bindTimeSeriesInteractions, type ChartReadout } from '@/lib/charts/time-series'
+import { createDashboardCursorStore } from '@/stores/dashboards/cursor'
+
+echarts.setPlatformAPI({ measureText: (text) => ({ width: String(text).length * 6 }) })
+echarts.use(chartSummaryExtension)
+
+function makeChart() {
+ return echarts.init(
+ document.createElement('div'),
+ {},
+ { renderer: 'svg', ssr: true, width: 600, height: 300 }
+ )
+}
+
+describe('resolved chart summaries', () => {
+ it('averages percentages, totals counts after transforms, and preserves series colors and names', () => {
+ const chart = makeChart()
+ let summary: ChartReadout | null = null
+ const stop = observeChartSummary(chart, (model) => {
+ summary = summarizeChart(model, {})
+ })
+ chart.setOption({
+ animation: false,
+ dataset: [
+ {
+ source: [
+ { time: '2026-09-20', cpu: 20, count: 2 },
+ { time: '2026-09-21', cpu: 80, count: 8 },
+ { time: '2026-09-22', cpu: null, count: 0 },
+ { time: '2026-09-23', cpu: 100, count: 100 },
+ ],
+ },
+ { transform: { type: 'filter', config: { dimension: 'count', lt: 100 } } },
+ ],
+ xAxis: { type: 'time' },
+ yAxis: [{ type: 'value', axisLabel: { formatter: '{value}%' } }, { type: 'value' }],
+ series: [
+ {
+ name: 'CPU',
+ type: 'line',
+ datasetIndex: 1,
+ encode: { x: 'time', y: 'cpu' },
+ itemStyle: { color: '#123456' },
+ },
+ {
+ name: 'Reports',
+ type: 'line',
+ datasetIndex: 1,
+ yAxisIndex: 1,
+ encode: { x: 'time', y: 'count' },
+ itemStyle: { color: '#654321' },
+ },
+ ],
+ })
+ expect(summary).toEqual({
+ time: null,
+ values: [
+ { name: 'CPU', value: '50%', color: '#123456', summary: 'Avg' },
+ { name: 'Reports', value: '10', color: '#654321', summary: 'Total' },
+ ],
+ })
+ stop()
+ chart.dispose()
+ })
+
+ it('keeps missing data distinct from actual zero and excludes hidden legend series', () => {
+ const chart = makeChart()
+ let summary: ChartReadout | null = null
+ const stop = observeChartSummary(chart, (model) => {
+ summary = summarizeChart(model, {})
+ })
+ chart.setOption({
+ animation: false,
+ legend: { selected: { Hidden: false } },
+ xAxis: { type: 'time' },
+ yAxis: { axisLabel: { formatter: '{value}%' } },
+ series: [
+ {
+ name: 'Zero',
+ type: 'line',
+ data: [
+ ['2026-09-20', 0],
+ ['2026-09-21', null],
+ ],
+ },
+ { name: 'Missing', type: 'line', data: [['2026-09-20', null]] },
+ { name: 'Hidden', type: 'line', data: [['2026-09-20', 100]] },
+ ],
+ })
+ expect(summary).toMatchObject({
+ values: [
+ { name: 'Zero', value: '0%' },
+ { name: 'Missing', value: '—' },
+ ],
+ })
+ stop()
+ chart.dispose()
+ })
+
+ it('restores a summary after hover and recomputes it on new data', () => {
+ const chart = makeChart()
+ let readout: ChartReadout | null = null
+ const controller = bindTimeSeriesInteractions(chart, {
+ range: { from: '2026-09-20T00:00:00Z', to: '2026-09-22T00:00:00Z' },
+ firstTime: Date.parse('2026-09-20'),
+ timeZone: 'UTC',
+ columnLabels: {},
+ cursorStore: createDashboardCursorStore(),
+ onReadout: (next) => {
+ readout = next
+ },
+ })
+ const option = (value: number) =>
+ controller.prepareOption({
+ animation: false,
+ xAxis: { type: 'time' },
+ yAxis: {},
+ series: [
+ {
+ name: 'Reports',
+ type: 'line',
+ data: [
+ ['2026-09-20', value],
+ ['2026-09-21', 3],
+ ],
+ },
+ ],
+ })
+ chart.setOption(option(2))
+ controller.afterUpdate()
+ expect(readout).toMatchObject({
+ time: null,
+ values: [{ value: '5', summary: 'Total' }],
+ })
+ chart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: 0 })
+ chart.dispatchAction({ type: 'hideTip' })
+ expect(readout).toMatchObject({ values: [{ value: '5' }] })
+ chart.setOption(option(7), { notMerge: true })
+ controller.afterUpdate()
+ expect(readout).toMatchObject({ values: [{ value: '10' }] })
+ controller.dispose()
+ chart.dispose()
+ })
+})
+
+describe('chart value formatting', () => {
+ it('keeps small magnitudes visible while rounding ordinary values to two decimals', () => {
+ expect(formatChartValue(0.004)).toBe('0.004')
+ expect(formatChartValue(0.30000000000000004)).toBe('0.3')
+ expect(formatChartValue(66.666, '{value}%')).toBe('66.67%')
+ })
+})
diff --git a/apps/sim/lib/charts/summary.ts b/apps/sim/lib/charts/summary.ts
new file mode 100644
index 00000000000..0744eb5bf91
--- /dev/null
+++ b/apps/sim/lib/charts/summary.ts
@@ -0,0 +1,76 @@
+import { toRecord } from '@sim/utils/object'
+import type { EChartsType, registerUpdateLifecycle } from 'echarts'
+import type { ChartReadout, ChartReadoutValue } from '@/lib/charts/time-series'
+
+type ChartModel = Parameters>[1]>[0]
+const listeners = new WeakMap void>()
+
+/** ECharts' extension lifecycle exposes the resolved series, including dataset transforms. */
+export function chartSummaryExtension(registers: {
+ registerUpdateLifecycle: typeof registerUpdateLifecycle
+}) {
+ registers.registerUpdateLifecycle('afterupdate', (model, api) => {
+ listeners.get(api.getDom())?.(model)
+ })
+}
+
+export function observeChartSummary(chart: EChartsType, listener: (model: ChartModel) => void) {
+ const element = chart.getDom()
+ listeners.set(element, listener)
+ return () => {
+ if (listeners.get(element) === listener) listeners.delete(element)
+ }
+}
+
+/** Two decimals from 1 upward; smaller magnitudes keep three significant digits instead of rounding to 0. */
+export function formatChartValue(value: unknown, formatter?: string): string {
+ if (value == null || value === '-' || (typeof value === 'number' && !Number.isFinite(value)))
+ return '—'
+ const text =
+ typeof value === 'number'
+ ? value.toLocaleString(
+ undefined,
+ Math.abs(value) >= 1 || value === 0
+ ? { maximumFractionDigits: 2 }
+ : { maximumSignificantDigits: 3 }
+ )
+ : String(value)
+ return formatter?.includes('{value}') ? formatter.replaceAll('{value}', text) : text
+}
+
+/** Summaries use original plotted samples, before display sampling or stacking. Missing samples stay missing. */
+export function summarizeChart(model: ChartModel, labels: Record): ChartReadout {
+ const values: ChartReadoutValue[] = []
+ model.eachSeries((series) => {
+ if (series.get('coordinateSystem') !== 'cartesian2d') return
+ const data = series.getRawData()
+ const axis = model.getComponent('yAxis', Number(toRecord(series.option).yAxisIndex ?? 0))
+ const format = toRecord(toRecord(axis?.option).axisLabel).formatter
+ const formatter = typeof format === 'string' ? format : undefined
+ const percentage = formatter?.includes('%') ?? false
+ const style = toRecord(series.getData().getVisual('style'))
+ const color = style.fill ?? style.stroke
+ for (const dimension of data.mapDimensionsAll('y')) {
+ let sum = 0
+ let count = 0
+ for (let index = 0; index < data.count(); index++) {
+ const value = data.get(dimension, index)
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ sum += value
+ count++
+ }
+ }
+ const name =
+ series.name && !series.name.includes('\u0000')
+ ? series.name
+ : (labels[dimension] ?? dimension)
+ values.push({
+ name,
+ value: formatChartValue(count ? (percentage ? sum / count : sum) : null, formatter),
+ color: typeof color === 'string' ? color : null,
+ summary: percentage ? 'Avg' : 'Total',
+ })
+ }
+ })
+ return { time: null, values }
+}
diff --git a/apps/sim/lib/charts/theme.test.ts b/apps/sim/lib/charts/theme.test.ts
new file mode 100644
index 00000000000..f5e58d25b47
--- /dev/null
+++ b/apps/sim/lib/charts/theme.test.ts
@@ -0,0 +1,123 @@
+import { toRecord } from '@sim/utils/object'
+import { describe, expect, it } from 'vitest'
+import { applyChartTooltipDefaults, formatBarTooltip, formatPieTooltip } from '@/lib/charts/theme'
+
+describe('compact bar tooltips', () => {
+ it('uses the value encoding, including numeric categories and missing values', () => {
+ const entry = {
+ axisValueLabel: '2026',
+ seriesName: 'series\u00000',
+ encode: { x: [1], y: [0] },
+ dimensionNames: ['year', 'reports'],
+ value: { year: 2026, reports: 10 },
+ }
+ expect(formatBarTooltip(entry, 'x')).toBe('2026: 10')
+ expect(formatBarTooltip({ ...entry, value: { year: 2026, reports: null } }, 'x')).toBe(
+ '2026: —'
+ )
+ })
+ it('formats each series with its own value axis', () => {
+ const option = applyChartTooltipDefaults({
+ xAxis: { type: 'category' },
+ yAxis: [{ type: 'value', axisLabel: { formatter: '{value}%' } }, { type: 'value' }],
+ series: [
+ { type: 'bar', name: 'Rate' },
+ { type: 'bar', name: 'Count', yAxisIndex: 1 },
+ ],
+ })
+ const formatter = toRecord(option.tooltip).formatter
+ if (typeof formatter !== 'function') throw new Error('Expected the bar tooltip formatter')
+ expect(
+ formatter([
+ { seriesIndex: 0, seriesName: 'Rate', axisValueLabel: 'Mon', value: 75 },
+ { seriesIndex: 1, seriesName: 'Count', axisValueLabel: 'Mon', value: 75 },
+ ])
+ ).toBe('Mon · Rate: 75%\nMon · Count: 75')
+ })
+ it('uses a floating tooltip with row hover and preserves authored overrides', () => {
+ expect(applyChartTooltipDefaults({ series: [{ type: 'bar' }] }).tooltip).toMatchObject({
+ trigger: 'axis',
+ showContent: true,
+ axisPointer: { type: 'shadow' },
+ })
+ const tooltip = {
+ trigger: 'item',
+ showContent: false,
+ axisPointer: { type: 'line' },
+ formatter: '{b}: {c}',
+ padding: 12,
+ }
+ expect(applyChartTooltipDefaults({ series: [{ type: 'bar' }], tooltip }).tooltip).toEqual(
+ tooltip
+ )
+ expect(applyChartTooltipDefaults({ series: [{ type: 'line' }] })).not.toHaveProperty('tooltip')
+ })
+ it('formats a dataset-backed horizontal percentage with the value axis units', () => {
+ const option = applyChartTooltipDefaults({
+ xAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
+ yAxis: { type: 'category' },
+ series: [{ type: 'bar' }],
+ })
+ const tooltip = option.tooltip as { formatter: (params: unknown) => string }
+ expect(
+ tooltip.formatter({
+ name: 'Carrier',
+ value: { carrier: 'Carrier', rate: 87.25 },
+ dimensionNames: ['carrier', 'rate'],
+ encode: { x: [1], y: [0] },
+ })
+ ).toBe('Carrier: 87.25%')
+ })
+})
+
+describe('pie tooltips', () => {
+ it.each([{ topic: 'Human resolved', tickets: 437 }, ['Human resolved', 437], 437])(
+ 'reads the measure from object rows, array rows and scalar data',
+ (value) => {
+ expect(
+ formatPieTooltip({
+ name: 'Human resolved',
+ value,
+ dimensionNames: ['topic', 'tickets'],
+ encode: { value: [1] },
+ percent: 25.23,
+ })
+ ).toBe('Human resolved: 437 (25.23%)')
+ }
+ )
+
+ it('preserves an explicit pie formatter', () => {
+ expect(
+ applyChartTooltipDefaults({
+ series: [{ type: 'pie', encode: { itemName: 'topic', value: 'tickets' } }],
+ tooltip: { formatter: '{b}: {d}%' },
+ }).tooltip
+ ).toMatchObject({ formatter: '{b}: {d}%' })
+ })
+
+ it('formats pies encoded by dimension index with the encoded measure', () => {
+ const option = applyChartTooltipDefaults({
+ series: [{ type: 'pie', encode: { itemName: 0, value: 1 } }],
+ })
+ const formatter = toRecord(option.tooltip).formatter
+ if (typeof formatter !== 'function') throw new Error('Expected the pie tooltip formatter')
+ expect(
+ formatter({
+ seriesIndex: 0,
+ name: 'Human resolved',
+ value: ['Human resolved', 437],
+ dimensionNames: ['topic', 'tickets'],
+ encode: { value: [1] },
+ percent: 25,
+ })
+ ).toBe('Human resolved: 437 (25%)')
+ })
+
+ it('keeps native formatting for pies with automatic encodings', () => {
+ expect(
+ applyChartTooltipDefaults({
+ series: [{ type: 'pie', data: [{ name: 'Reports', value: 11 }] }],
+ })
+ ).not.toHaveProperty('tooltip')
+ })
+})
diff --git a/apps/sim/lib/charts/theme.ts b/apps/sim/lib/charts/theme.ts
new file mode 100644
index 00000000000..86063bae94f
--- /dev/null
+++ b/apps/sim/lib/charts/theme.ts
@@ -0,0 +1,172 @@
+import { isRecordLike, toRecord } from '@sim/utils/object'
+import { CHART_BAR_MAX_WIDTH, mapTooltipEntries } from '@/lib/charts/option'
+import { formatChartValue } from '@/lib/charts/summary'
+
+function encodedTooltipValue(entry: Record, dimension: 'x' | 'y' | 'value') {
+ const dimensions = Array.isArray(entry.dimensionNames) ? entry.dimensionNames : []
+ const encoded = toRecord(entry.encode)[dimension]
+ const indices = Array.isArray(encoded) ? encoded : []
+ if (!indices.length) return entry.value
+ const index = indices[0]
+ return Array.isArray(entry.value)
+ ? entry.value[index]
+ : isRecordLike(entry.value)
+ ? entry.value[dimensions[index]]
+ : entry.value
+}
+
+/** A single category/value line avoids ECharts' empty series-name row for unnamed bars. */
+export function formatBarTooltip(
+ params: unknown,
+ valueAxis: 'x' | 'y' = 'y',
+ valueFormatter?: string | ((seriesIndex: number) => string | undefined)
+): string {
+ const entries = (Array.isArray(params) ? params : [params]).filter(isRecordLike)
+ return entries
+ .map((entry) => {
+ const value = encodedTooltipValue(entry, valueAxis)
+ const category = entry.axisValueLabel ?? entry.name ?? ''
+ const name =
+ typeof entry.seriesName === 'string' && !entry.seriesName.includes('\u0000')
+ ? entry.seriesName
+ : ''
+ const label = entries.length > 1 && name ? `${category} · ${name}` : category || name
+ const formatter =
+ typeof valueFormatter === 'function'
+ ? valueFormatter(Number(entry.seriesIndex ?? 0))
+ : valueFormatter
+ return `${label}: ${formatChartValue(value, formatter)}`
+ })
+ .join('\n')
+}
+
+/** Dataset-backed pies expose the whole source row as value; resolve the encoded measure. */
+export function formatPieTooltip(params: unknown, valueField?: string): string {
+ const entry = toRecord(params)
+ const value = formatChartValue(
+ valueField && isRecordLike(entry.value)
+ ? entry.value[valueField]
+ : encodedTooltipValue(entry, 'value')
+ )
+ const percent = typeof entry.percent === 'number' ? ` (${formatChartValue(entry.percent)}%)` : ''
+ return `${entry.name}: ${value}${percent}`
+}
+
+/** Authored tooltip options take precedence over the shared chart defaults. */
+export function applyChartTooltipDefaults(option: Record) {
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ if (series.length && series.every((entry) => toRecord(entry).type === 'bar')) {
+ const yAxis = toRecord(Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis)
+ const valueAxisName = yAxis.type === 'category' ? 'x' : 'y'
+ const valueAxes = option[`${valueAxisName}Axis`]
+ /** Each series reads units from its own value axis, so a secondary axis keeps its format. */
+ const formatters = series.map((entry) => {
+ const index = Number(toRecord(entry)[`${valueAxisName}AxisIndex`] ?? 0)
+ const axis = toRecord(Array.isArray(valueAxes) ? valueAxes[index] : valueAxes)
+ const formatter = toRecord(axis.axisLabel).formatter
+ return typeof formatter === 'string' ? formatter : undefined
+ })
+ option.tooltip = mapTooltipEntries(option.tooltip, (tooltip) => ({
+ trigger: 'axis',
+ showContent: true,
+ formatter: (params: unknown) =>
+ formatBarTooltip(params, valueAxisName, (seriesIndex) => formatters[seriesIndex]),
+ ...tooltip,
+ axisPointer: { type: 'shadow', ...toRecord(tooltip.axisPointer) },
+ }))
+ } else if (series.length && series.every((entry) => toRecord(entry).type === 'pie')) {
+ const valueFields = series.map((entry) => {
+ const encoded = toRecord(toRecord(entry).encode).value
+ return Array.isArray(encoded) ? encoded[0] : encoded
+ })
+ /**
+ * Native formatting handles automatic encodings. Table charts name their measure; indexed
+ * encodings resolve through the encode and dimension names ECharts passes the formatter.
+ */
+ if (!valueFields.every((field) => typeof field === 'string' || typeof field === 'number'))
+ return option
+ option.tooltip = mapTooltipEntries(option.tooltip, (tooltip) => ({
+ trigger: 'item',
+ formatter: (params: unknown) => {
+ const field = valueFields[Number(toRecord(params).seriesIndex)]
+ return formatPieTooltip(params, typeof field === 'string' ? field : undefined)
+ },
+ ...tooltip,
+ }))
+ }
+ return option
+}
+
+/** Canvas cannot resolve CSS variables; read the same tokens as EMCN at its own container. */
+export function readEmcnChartTheme(element: HTMLElement): Record {
+ const styles = getComputedStyle(element)
+ const token = (name: string) => {
+ const value = styles.getPropertyValue(name).trim()
+ if (!value) throw new Error(`Missing chart theme token ${name}`)
+ return value
+ }
+ const text = token('--text-body')
+ const muted = token('--text-tertiary')
+ const border = token('--border')
+ const fontFamily = styles.fontFamily
+ const axis = {
+ axisLine: { show: false, lineStyle: { color: border } },
+ axisTick: { show: false },
+ axisLabel: { color: muted, fontFamily, fontSize: 13, margin: 12, hideOverlap: true },
+ nameTextStyle: { color: muted, fontFamily, fontSize: 13 },
+ splitLine: { lineStyle: { color: border, width: 0.5, type: 'solid' } },
+ splitNumber: 4,
+ }
+ return {
+ color: [text, token('--text-subtle'), token('--surface-7'), token('--text-icon')],
+ backgroundColor: 'transparent',
+ axisPointer: { shadowStyle: { color: text, opacity: 0.06 } },
+ animationDuration: 0,
+ textStyle: { fontFamily, fontSize: 13, color: text },
+ title: {
+ textStyle: { fontFamily, fontSize: 13, fontWeight: 'normal', color: text },
+ },
+ legend: {
+ textStyle: { color: muted, fontFamily, fontSize: 13 },
+ itemWidth: 8,
+ itemHeight: 8,
+ itemGap: 16,
+ },
+ tooltip: {
+ renderMode: 'richText',
+ confine: true,
+ backgroundColor: token('--surface-1'),
+ borderColor: border,
+ borderWidth: 1,
+ padding: [6, 12],
+ borderRadius: 6,
+ shadowBlur: 0,
+ shadowOffsetX: 0,
+ shadowOffsetY: 0,
+ textStyle: { fontFamily, color: text, fontSize: 13, fontWeight: 'normal', lineHeight: 20 },
+ },
+ categoryAxis: { ...axis, splitLine: { show: false } },
+ valueAxis: axis,
+ timeAxis: { ...axis, splitLine: { show: false } },
+ logAxis: axis,
+ line: { symbolSize: 4, lineStyle: { width: 1.5 }, showSymbol: false },
+ bar: {
+ barMaxWidth: CHART_BAR_MAX_WIDTH,
+ label: { fontFamily, fontSize: 13, color: text },
+ itemStyle: { borderRadius: 2 },
+ },
+ pie: {
+ label: {
+ fontFamily,
+ fontSize: 13,
+ lineHeight: 18,
+ color: text,
+ alignTo: 'edge',
+ edgeDistance: 8,
+ overflow: 'break',
+ },
+ labelLine: { length: 12, length2: 8 },
+ itemStyle: { borderColor: token('--bg'), borderWidth: 2 },
+ },
+ }
+}
diff --git a/apps/sim/lib/charts/time-series.test.ts b/apps/sim/lib/charts/time-series.test.ts
new file mode 100644
index 00000000000..1c31f849f9a
--- /dev/null
+++ b/apps/sim/lib/charts/time-series.test.ts
@@ -0,0 +1,164 @@
+/** @vitest-environment jsdom */
+import type { EChartsType } from 'echarts'
+import { describe, expect, it, vi } from 'vitest'
+import { bindTimeSeriesInteractions, readTimeSeriesTooltip } from '@/lib/charts/time-series'
+import { createDashboardCursorStore } from '@/stores/dashboards/cursor'
+
+const range = { from: '2026-09-20T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' }
+const time = Date.parse('2026-09-21T00:00:00Z')
+function makeChart(id: string) {
+ const handlers = new Map void>()
+ const chart = {
+ getId: () => id,
+ getDom: () => document.createElement('div'),
+ getHeight: () => 220,
+ convertToPixel: vi.fn((_finder: unknown, value: number) => value / 10000),
+ dispatchAction: vi.fn((action: Record) => {
+ if (action.type === 'updateAxisPointer')
+ handlers.get('updateAxisPointer')?.({ axesInfo: [{ axisDim: 'x', value: time }] })
+ if (action.type === 'hideTip') handlers.get('hideTip')?.({})
+ }),
+ on: (name: string, handler: (event: unknown) => void) => handlers.set(name, handler),
+ off: (name: string) => handlers.delete(name),
+ }
+ return { chart, instance: chart as unknown as EChartsType, handlers }
+}
+
+describe('time chart interactions', () => {
+ it('reads the resolved dataset encodings, authored names/colors, and null values', () => {
+ expect(
+ readTimeSeriesTooltip(
+ [
+ {
+ axisValue: time,
+ seriesName: 'CPU',
+ color: '#2563eb',
+ encode: { y: [1] },
+ dimensionNames: ['timestamp', 'cpu'],
+ value: { timestamp: time, cpu: 42 },
+ },
+ {
+ axisValue: time,
+ seriesName: 'series\u00000',
+ color: '#16a34a',
+ encode: { y: [1] },
+ dimensionNames: ['timestamp', 'memory'],
+ value: [time, null],
+ },
+ ],
+ { memory: 'Memory' }
+ )
+ ).toEqual({
+ time,
+ values: [
+ { name: 'CPU', value: '42', color: '#2563eb' },
+ { name: 'Memory', value: '—', color: '#16a34a' },
+ ],
+ })
+ })
+ it('preserves authored styling while installing trusted interaction handlers', () => {
+ const { instance } = makeChart('one')
+ const controller = bindTimeSeriesInteractions(instance, {
+ range,
+ timeZone: 'UTC',
+ cursorStore: createDashboardCursorStore(),
+ columnLabels: {},
+ firstTime: time,
+ onReadout: vi.fn(),
+ onZoom: vi.fn(),
+ })
+ const option = controller.prepareOption({
+ color: ['red'],
+ xAxis: { type: 'time', axisLabel: { formatter: '{MMM}' } },
+ series: [{ type: 'line', lineStyle: { width: 3, color: 'blue' } }],
+ })
+ expect(option).toMatchObject({
+ color: ['red'],
+ xAxis: { axisLabel: { formatter: '{MMM}' } },
+ series: [{ lineStyle: { width: 3, color: 'blue' } }],
+ toolbox: { show: false },
+ tooltip: { renderMode: 'richText' },
+ })
+ controller.dispose()
+ })
+ it('shows timestamp and decimal values only in the hovered chart tooltip', () => {
+ const { instance } = makeChart('one')
+ const store = createDashboardCursorStore()
+ const controller = bindTimeSeriesInteractions(instance, {
+ range,
+ timeZone: 'America/Los_Angeles',
+ cursorStore: store,
+ columnLabels: {},
+ firstTime: time,
+ onReadout: () => {},
+ })
+ const option = controller.prepareOption({
+ xAxis: { type: 'time' },
+ yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
+ series: [{ type: 'line' }],
+ })
+ const tooltip = option.tooltip as { formatter: (params: unknown) => string }
+ const params = [
+ {
+ axisValue: time,
+ seriesIndex: 0,
+ seriesName: 'Resolved',
+ dimensionNames: ['timestamp', 'rate'],
+ encode: { y: [1] },
+ value: { timestamp: time, rate: 71.63 },
+ },
+ ]
+ store.getState().setCursor({ owner: 'one', group: `${range.from}/${range.to}`, time })
+ expect(tooltip.formatter(params)).toBe('Sep 20, 17:00 PDT\nResolved: 71.63%')
+ store.getState().setCursor({ owner: 'other', group: `${range.from}/${range.to}`, time })
+ expect(tooltip.formatter(params)).toBe('')
+ controller.dispose()
+ expect(tooltip.formatter(params)).toBe('')
+ })
+ it('shares the hovered timestamp through the cursor store and clears it on leave', () => {
+ const store = createDashboardCursorStore()
+ const first = makeChart('one')
+ const second = makeChart('two')
+ const third = makeChart('override')
+ const config = {
+ range,
+ timeZone: 'UTC',
+ cursorStore: store,
+ columnLabels: {},
+ firstTime: time,
+ onReadout: () => {},
+ }
+ const bindings = [
+ bindTimeSeriesInteractions(first.instance, config),
+ bindTimeSeriesInteractions(second.instance, config),
+ bindTimeSeriesInteractions(third.instance, {
+ ...config,
+ range: { ...range, from: '2026-09-21T00:00:00Z' },
+ }),
+ ]
+ first.handlers.get('updateAxisPointer')?.({ axesInfo: [{ axisDim: 'x', value: time }] })
+ expect(store.getState().cursor?.owner).toBe('one')
+ first.handlers.get('hideTip')?.({})
+ expect(store.getState().cursor).toBeNull()
+ bindings.forEach((binding) => binding.dispose())
+ })
+ it('zooms only after a meaningful completed brush and ignores clicks', () => {
+ const { instance, handlers } = makeChart('one')
+ const zooms: unknown[] = []
+ const controller = bindTimeSeriesInteractions(instance, {
+ range,
+ timeZone: 'UTC',
+ cursorStore: createDashboardCursorStore(),
+ columnLabels: {},
+ firstTime: time,
+ onReadout: () => {},
+ onZoom: (zoom) => zooms.push(zoom),
+ })
+ expect(handlers.has('brush')).toBe(false)
+ handlers.get('brushEnd')?.({ areas: [{ coordRange: [time, time + 3600000], range: [30, 31] }] })
+ expect(zooms).toEqual([])
+ handlers.get('brushEnd')?.({ areas: [{ coordRange: [time + 3600000, time], range: [80, 30] }] })
+ expect(zooms).toEqual([{ from: '2026-09-21T00:00:00.000Z', to: '2026-09-21T01:00:00.000Z' }])
+ controller.dispose()
+ })
+})
diff --git a/apps/sim/lib/charts/time-series.ts b/apps/sim/lib/charts/time-series.ts
new file mode 100644
index 00000000000..b5ef62073a5
--- /dev/null
+++ b/apps/sim/lib/charts/time-series.ts
@@ -0,0 +1,228 @@
+import { isRecordLike, toRecord } from '@sim/utils/object'
+import type { EChartsType } from 'echarts'
+import type { EChartsController } from '@/components/charts/echarts-view'
+import { formatChartValue, observeChartSummary, summarizeChart } from '@/lib/charts/summary'
+import {
+ type DashboardTimeRange,
+ dashboardAxisFormatter,
+ dashboardTimeLabel,
+ dashboardZoomRange,
+} from '@/lib/dashboards/time'
+import type { DashboardCursorStore } from '@/stores/dashboards/cursor'
+
+export interface ChartReadoutValue {
+ name: string
+ value: string
+ color: string | null
+ summary?: 'Avg' | 'Total'
+}
+export interface ChartReadout {
+ time: number | null
+ values: ChartReadoutValue[]
+}
+export interface TimeSeriesInteractionOptions {
+ range: DashboardTimeRange
+ timeZone: string
+ cursorStore: DashboardCursorStore
+ columnLabels: Record
+ firstTime: number | null
+ onReadout: (readout: ChartReadout | null) => void
+ onZoom?: (range: DashboardTimeRange) => void
+}
+
+/** Cartesian charts with one horizontal time axis share dashboard interactions. */
+export function isTimeSeriesOption(option: Record): boolean {
+ const axes = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis]
+ return axes.length === 1 && toRecord(axes[0]).type === 'time'
+}
+
+/** Use ECharts' resolved encodings and colors, including transformed datasets. */
+export function readTimeSeriesTooltip(
+ params: unknown,
+ columnLabels: Record,
+ formatters: Record = {}
+): ChartReadout | null {
+ const entries = (Array.isArray(params) ? params : [params]).filter(isRecordLike)
+ if (entries[0]?.axisValue == null) return null
+ const time = Number(entries[0]?.axisValue)
+ if (!Number.isFinite(time) || entries.length === 0) return null
+ const values = entries.flatMap((entry): ChartReadoutValue[] => {
+ const dimensions = Array.isArray(entry.dimensionNames) ? entry.dimensionNames : []
+ const encoded = toRecord(entry.encode).y
+ const indices = Array.isArray(encoded) ? encoded : []
+ return indices.map((index) => {
+ const field = typeof index === 'number' ? dimensions[index] : undefined
+ const value = Array.isArray(entry.value)
+ ? entry.value[index]
+ : isRecordLike(entry.value) && typeof field === 'string'
+ ? entry.value[field]
+ : entry.value
+ const seriesName =
+ typeof entry.seriesName === 'string' && !entry.seriesName.includes('\u0000')
+ ? entry.seriesName
+ : null
+ return {
+ name: seriesName || (typeof field === 'string' ? (columnLabels[field] ?? field) : 'Value'),
+ value: formatChartValue(value, formatters[Number(entry.seriesIndex)]),
+ color: typeof entry.color === 'string' ? entry.color : null,
+ }
+ })
+ })
+ return { time, values }
+}
+
+/** Trusted event handlers wrap sanitized ECharts options; documents never contain executable code. */
+export function bindTimeSeriesInteractions(
+ chart: EChartsType,
+ config: TimeSeriesInteractionOptions
+): EChartsController {
+ const { range, cursorStore, timeZone } = config
+ const group = `${range.from}/${range.to}`
+ const owner = chart.getId()
+ let internal = false
+ let disposed = false
+ let summary: ChartReadout | null = null
+ const formatters: Record = {}
+ const stopSummary = observeChartSummary(chart, (model) => {
+ summary = summarizeChart(model, config.columnLabels)
+ if (cursorStore.getState().cursor?.group !== group) config.onReadout(summary)
+ })
+ const runInternal = (action: () => void) => {
+ internal = true
+ try {
+ action()
+ } finally {
+ internal = false
+ }
+ }
+ const pointAt = (time: number) => ({
+ x: chart.convertToPixel({ xAxisIndex: 0 }, time),
+ y: chart.getHeight() / 2,
+ })
+ const showSummary = () =>
+ runInternal(() => {
+ chart.dispatchAction({ type: 'hideTip' })
+ chart.dispatchAction({ type: 'updateAxisPointer', currTrigger: 'leave' })
+ config.onReadout(summary)
+ })
+ const sync = () => {
+ const cursor = cursorStore.getState().cursor
+ if (cursor?.owner === owner) return
+ if (cursor?.group === group) {
+ runInternal(() =>
+ chart.dispatchAction({ type: 'updateAxisPointer', ...pointAt(cursor.time) })
+ )
+ } else showSummary()
+ }
+ const onPointer = (event: unknown) => {
+ if (internal || disposed) return
+ const axes = toRecord(event).axesInfo
+ const x = Array.isArray(axes) ? axes.find((axis) => toRecord(axis).axisDim === 'x') : null
+ const time = toRecord(x).value
+ if (typeof time === 'number' && Number.isFinite(time)) {
+ cursorStore.getState().setCursor({ owner, group, time })
+ }
+ }
+ const onLeave = () => {
+ if (internal || disposed) return
+ cursorStore.getState().clearCursor(owner)
+ showSummary()
+ }
+ const clearBrush = () => chart.dispatchAction({ type: 'brush', areas: [] })
+ const onBrushEnd = (event: unknown) => {
+ if (disposed || !config.onZoom) return
+ const areas = toRecord(event).areas
+ const area = Array.isArray(areas) ? toRecord(areas[0]) : {}
+ const pixels = area.range
+ const zoom = dashboardZoomRange(area.coordRange, range)
+ clearBrush()
+ if (!zoom || !Array.isArray(pixels) || Math.abs(pixels[1] - pixels[0]) < 6) return
+ cursorStore.getState().clearCursor()
+ config.onZoom(zoom)
+ }
+ const unsubscribe = cursorStore.subscribe((state, previous) => {
+ if (state.cursor?.group === group || previous.cursor?.group === group) sync()
+ })
+ chart.on('updateAxisPointer', onPointer)
+ chart.on('hideTip', onLeave)
+ chart.on('brushEnd', onBrushEnd)
+ return {
+ prepareOption(option) {
+ const yAxes = Array.isArray(option.yAxis) ? option.yAxis : [option.yAxis]
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ series.forEach((entry, index) => {
+ const yAxis = toRecord(yAxes[Number(toRecord(entry).yAxisIndex ?? 0)])
+ const formatter = toRecord(yAxis.axisLabel).formatter
+ if (typeof formatter === 'string') formatters[index] = formatter
+ })
+ const axis = toRecord(Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis)
+ const axisLabel = toRecord(axis.axisLabel)
+ const styles = getComputedStyle(chart.getDom())
+ option.useUTC = true
+ option.animationDurationUpdate ??= 0
+ /** The time axis always spans the queried range, so zoom and hover stay aligned with the data. */
+ option.xAxis = {
+ ...axis,
+ min: Math.min(Date.parse(range.from), config.firstTime ?? Number.POSITIVE_INFINITY),
+ max: Date.parse(range.to),
+ axisLabel: { formatter: dashboardAxisFormatter(range, timeZone), ...axisLabel },
+ }
+ option.tooltip = {
+ ...toRecord(option.tooltip),
+ trigger: 'axis',
+ show: true,
+ showContent: true,
+ renderMode: 'richText',
+ axisPointer: { type: 'line', snap: true, label: { show: false } },
+ formatter: (params: unknown) => {
+ if (disposed) return ''
+ const readout = readTimeSeriesTooltip(params, config.columnLabels, formatters)
+ config.onReadout(readout)
+ const cursor = cursorStore.getState().cursor
+ if (readout?.time == null || (cursor?.group === group && cursor.owner !== owner))
+ return ''
+ return [
+ dashboardTimeLabel(readout.time, timeZone),
+ ...readout.values.map((entry) => `${entry.name}: ${entry.value}`),
+ ].join('\n')
+ },
+ }
+ if (config.onZoom) {
+ option.toolbox = { show: false }
+ option.brush = {
+ xAxisIndex: 0,
+ brushType: 'lineX',
+ brushMode: 'single',
+ transformable: false,
+ seriesIndex: [],
+ removeOnClick: true,
+ brushStyle: {
+ color: styles.getPropertyValue('--text-body').trim(),
+ borderColor: styles.getPropertyValue('--text-body').trim(),
+ borderWidth: 1,
+ opacity: 0.12,
+ },
+ }
+ }
+ return option
+ },
+ afterUpdate() {
+ if (config.onZoom)
+ chart.dispatchAction({
+ type: 'takeGlobalCursor',
+ key: 'brush',
+ brushOption: { brushType: 'lineX', brushMode: 'single' },
+ })
+ sync()
+ },
+ dispose() {
+ disposed = true
+ stopSummary()
+ unsubscribe()
+ chart.off('updateAxisPointer', onPointer)
+ chart.off('hideTip', onLeave)
+ chart.off('brushEnd', onBrushEnd)
+ cursorStore.getState().clearCursor(owner)
+ },
+ }
+}
diff --git a/apps/sim/lib/charts/tooltip.test.ts b/apps/sim/lib/charts/tooltip.test.ts
new file mode 100644
index 00000000000..fd8c02a54fb
--- /dev/null
+++ b/apps/sim/lib/charts/tooltip.test.ts
@@ -0,0 +1,126 @@
+/** @vitest-environment jsdom */
+import * as echarts from 'echarts'
+import { describe, expect, it } from 'vitest'
+import { applyChartTooltipDefaults } from '@/lib/charts/theme'
+
+/** Distinct font metrics make a lost font visible in both layout and rendered text. */
+echarts.setPlatformAPI({
+ measureText: (text, font) => ({
+ width: String(text).length * (font?.includes('Season Sans') ? 8 : 6),
+ }),
+})
+
+describe('ECharts rich-text tooltip font patch', () => {
+ it('renders the encoded pie count and percent from a table dataset', () => {
+ const chart = echarts.init(
+ document.createElement('div'),
+ {},
+ {
+ renderer: 'svg',
+ width: 600,
+ height: 300,
+ }
+ )
+ try {
+ chart.setOption(
+ applyChartTooltipDefaults({
+ animation: false,
+ dataset: {
+ source: [
+ { outcome: 'AI resolved', tickets: 75 },
+ { outcome: 'Human resolved', tickets: 25 },
+ ],
+ },
+ tooltip: { renderMode: 'richText' },
+ series: [{ type: 'pie', encode: { itemName: 'outcome', value: 'tickets' } }],
+ })
+ )
+ chart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: 1 })
+ expect(
+ chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .map((element) => element.style.text)
+ .filter((text) => typeof text === 'string')
+ ).toEqual(expect.arrayContaining(['Human resolved: 25 (25%)']))
+ } finally {
+ chart.dispose()
+ }
+ })
+
+ it.each([
+ { fontFamily: 'Season Sans', fontSize: 12, fontWeight: 'normal' as const },
+ { fontFamily: 'Georgia', fontSize: 18, fontWeight: 'bold' as const },
+ ])('measures and renders a formatter with $fontFamily', (textStyle) => {
+ const chart = echarts.init(
+ document.createElement('div'),
+ {},
+ {
+ renderer: 'svg',
+ width: 600,
+ height: 300,
+ }
+ )
+ try {
+ chart.setOption({
+ animation: false,
+ tooltip: {
+ renderMode: 'richText',
+ formatter: 'Native canary failure: 11',
+ padding: [6, 12],
+ backgroundColor: '#ffffff',
+ textStyle: { ...textStyle, lineHeight: 18 },
+ },
+ xAxis: { type: 'value' },
+ yAxis: { type: 'category', data: ['Native canary failure'] },
+ series: [{ type: 'bar', data: [11] }],
+ })
+ chart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: 0 })
+ const elements = chart.getZr().storage.getDisplayList(true)
+ const text = elements.find((element) => element.style.text === 'Native canary failure: 11')
+ const box = elements.find(
+ (element) => element.type === 'rect' && element.style.fill === '#ffffff'
+ )
+ expect(text?.style.font).toContain(textStyle.fontFamily)
+ expect(text?.style.font).toContain(`${textStyle.fontSize}px`)
+ expect(text?.style.font).toContain(textStyle.fontWeight)
+ expect(box).toBeDefined()
+ /** Padding plus the one-pixel border drawn outside each side. */
+ expect(box!.getBoundingRect().width).toBeCloseTo(text!.getBoundingRect().width + 26)
+ expect(box!.getBoundingRect().height).toBe(32)
+ } finally {
+ chart.dispose()
+ }
+ })
+
+ it('applies the configured family to built-in tooltip names and values', () => {
+ const chart = echarts.init(
+ document.createElement('div'),
+ {},
+ {
+ renderer: 'svg',
+ width: 600,
+ height: 300,
+ }
+ )
+ try {
+ chart.setOption({
+ animation: false,
+ tooltip: {
+ renderMode: 'richText',
+ textStyle: { fontFamily: 'Season Sans', fontSize: 12, fontWeight: 'normal' },
+ },
+ series: [{ type: 'pie', data: [{ name: 'Reports', value: 11 }] }],
+ })
+ chart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: 0 })
+ const tooltipText = chart
+ .getZr()
+ .storage.getDisplayList(true)
+ .filter((element) => element.z === 60 && ['Reports', '11'].includes(element.style.text))
+ expect(tooltipText).toHaveLength(2)
+ for (const text of tooltipText) expect(text.style.font).toContain('Season Sans')
+ } finally {
+ chart.dispose()
+ }
+ })
+})
diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts
index b797f580a56..b6118e36d5e 100644
--- a/apps/sim/lib/consent/scripts.ts
+++ b/apps/sim/lib/consent/scripts.ts
@@ -1,6 +1,7 @@
import { ahrefsAnalytics } from '@c15t/scripts/ahrefs-analytics'
import { gtag } from '@c15t/scripts/google-tag'
import { xPixel } from '@c15t/scripts/x-pixel'
+import { FREEBUFF_TAG_SRC, installFreebuffStub } from '@/lib/analytics/freebuff'
export const GOOGLE_ANALYTICS_ID = 'G-DR7YBE70VS' as const
@@ -60,6 +61,19 @@ export const GLOBAL_CONSENT_SCRIPTS = [
},
},
ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }),
+ /**
+ * Global rather than landing-only: the ad lands on a marketing page but the
+ * conversion fires from `/signup`. The tag recovers `?bfcid=` from the
+ * original navigation entry, so a client-side route change before consent
+ * resolves does not lose the click id.
+ */
+ {
+ id: 'freebuff-tag',
+ src: FREEBUFF_TAG_SRC,
+ category: 'marketing',
+ async: true,
+ onBeforeLoad: installFreebuffStub,
+ },
] as const
/** Marketing-page integrations that should not load on a direct workspace visit. */
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.integration.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.integration.ts
new file mode 100644
index 00000000000..8ba419f040b
--- /dev/null
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.integration.ts
@@ -0,0 +1,130 @@
+import { db } from '@sim/db'
+import { idempotencyKey } from '@sim/db/schema'
+import { asyncJobsRegionMock } from '@sim/testing/mocks/async-jobs-region.mock'
+import { triggerSdkMock, triggerSdkMockFns } from '@sim/testing/mocks/trigger-sdk.mock'
+import { generateId } from '@sim/utils/id'
+import { inArray } from 'drizzle-orm'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TriggerDevJobQueue } from '@/lib/core/async-jobs/backends/trigger-dev'
+
+vi.mock('@trigger.dev/sdk', () => triggerSdkMock)
+vi.mock('@/lib/core/async-jobs/region', () => asyncJobsRegionMock)
+
+const receipts: string[] = []
+const runs = new Map<
+ string,
+ { id: string; taskIdentifier: string; status: string; payload: unknown; createdAt: Date }
+>()
+
+beforeEach(() => {
+ triggerSdkMockFns.mockRunsList.mockReset()
+ runs.clear()
+ triggerSdkMockFns.mockTasksTrigger.mockImplementation(async (type, payload) => {
+ const id = `run_${generateId()}`
+ runs.set(id, { id, taskIdentifier: type, status: 'QUEUED', payload, createdAt: new Date() })
+ return { id }
+ })
+ triggerSdkMockFns.mockRunsRetrieve.mockImplementation(async (id) => {
+ const run = runs.get(id)
+ if (!run) throw new Error('Run not found')
+ return run
+ })
+ triggerSdkMockFns.mockRunsCancel.mockImplementation(async (id) => {
+ const run = runs.get(id)
+ if (!run) throw new Error('Run not found')
+ run.status = 'CANCELED'
+ return run
+ })
+})
+
+afterAll(async () => {
+ if (receipts.length) await db.delete(idempotencyKey).where(inArray(idempotencyKey.key, receipts))
+})
+
+async function enqueue() {
+ const executionId = generateId()
+ const workflowId = generateId()
+ const rootJobId = `workflow-execution:${executionId}`
+ receipts.push(`trigger-job:${rootJobId}`)
+ const id = await new TriggerDevJobQueue().enqueue(
+ 'workflow-execution',
+ { executionId, workflowId },
+ { jobId: rootJobId }
+ )
+ return { id, binding: { workflowId, executionId, rootJobId } }
+}
+
+describe('accepted Trigger runs before tag indexing', () => {
+ it('persists a receipt and lets another queue instance read the accepted run', async () => {
+ const { id, binding } = await enqueue()
+ const reader = new TriggerDevJobQueue()
+ expect(await reader.getJob(binding.rootJobId)).toMatchObject({
+ id,
+ status: 'pending',
+ metadata: { workflowId: binding.workflowId },
+ })
+ const stored = await db
+ .select()
+ .from(idempotencyKey)
+ .where(inArray(idempotencyKey.key, receipts))
+ expect(
+ stored.some(
+ (row) =>
+ row.key === `trigger-job:${binding.rootJobId}` &&
+ (row.result as { runId: string }).runId === id
+ )
+ ).toBe(true)
+ })
+
+ it('cancels an accepted run while every tag search is still empty', async () => {
+ const { id, binding } = await enqueue()
+ expect(await new TriggerDevJobQueue().cancelByExecution(binding, 'standalone')).toBe(1)
+ expect(runs.get(id)?.status).toBe('CANCELED')
+ })
+
+ it('keeps a retry discoverable and cancels a run only once when its tags catch up', async () => {
+ const { id, binding } = await enqueue()
+ triggerSdkMockFns.mockTasksTrigger.mockResolvedValueOnce({ id })
+ await new TriggerDevJobQueue().enqueue(
+ 'workflow-execution',
+ {
+ workflowId: binding.workflowId,
+ executionId: binding.executionId,
+ },
+ { jobId: binding.rootJobId }
+ )
+ triggerSdkMockFns.mockRunsList.mockImplementation(() => ({
+ async *[Symbol.asyncIterator]() {
+ yield {
+ id,
+ taskIdentifier: 'workflow-execution',
+ tags: [`workflowId:${binding.workflowId}`, `executionId:${binding.executionId}`],
+ }
+ },
+ }))
+ const queue = new TriggerDevJobQueue()
+ expect(await queue.getJob(binding.rootJobId)).toMatchObject({ id })
+ expect(await queue.cancelByExecution(binding, 'standalone')).toBe(1)
+ expect(runs.get(id)?.status).toBe('CANCELED')
+ })
+
+ it('does not cancel a receipt belonging to another workflow or cancellation scope', async () => {
+ const { id, binding } = await enqueue()
+ const queue = new TriggerDevJobQueue()
+ expect(
+ await queue.cancelByExecution({ ...binding, workflowId: generateId() }, 'standalone')
+ ).toBe(0)
+ expect(
+ await queue.cancelByExecution({ ...binding, executionId: generateId() }, 'standalone')
+ ).toBe(0)
+ expect(await queue.cancelByExecution(binding, 'resume')).toBe(0)
+ expect(runs.get(id)?.status).toBe('QUEUED')
+ })
+
+ it('does not report a completed receipt as a successful cancellation', async () => {
+ const { id, binding } = await enqueue()
+ runs.get(id)!.status = 'COMPLETED'
+ expect(await new TriggerDevJobQueue().cancelByExecution(binding, 'standalone')).toBe(0)
+ expect(runs.get(id)?.status).toBe('COMPLETED')
+ })
+})
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts
index 70247fdefd8..e4cc6089868 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts
@@ -1,7 +1,9 @@
+import { idempotencyKey } from '@sim/db/schema'
import {
asyncJobsRegionMock,
asyncJobsRegionMockFns,
} from '@sim/testing/mocks/async-jobs-region.mock'
+import { dbChainMockFns, queueTableRows } from '@sim/testing/mocks/database.mock'
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
import {
MockTriggerApiError as MockApiError,
@@ -170,6 +172,13 @@ describe('TriggerDevJobQueue enqueue', () => {
expect(mockTrigger).not.toHaveBeenCalled()
})
+ it('preserves ambiguous acceptance when the run receipt cannot be persisted', async () => {
+ dbChainMockFns.onConflictDoUpdate.mockRejectedValueOnce(new Error('database unavailable'))
+ await expect(
+ new TriggerDevJobQueue().enqueue('workflow-execution', {}, { jobId: 'workflow:1' })
+ ).rejects.toMatchObject({ acceptance: 'unknown', retryable: true })
+ })
+
it('classifies a client response as proven non-acceptance', async () => {
mockTrigger.mockRejectedValueOnce(new MockApiError(422, 'invalid payload'))
const queue = new TriggerDevJobQueue()
@@ -321,7 +330,7 @@ describe('TriggerDevJobQueue status mapping', () => {
describe('TriggerDevJobQueue cancellation', () => {
beforeEach(() => {
- mockList.mockReturnValue(
+ mockList.mockReset().mockReturnValue(
createListPage([
{
id: 'run-1',
@@ -468,6 +477,49 @@ describe('TriggerDevJobQueue cancellation', () => {
})
})
+ it.each(['receipt', 'retrieve', 'cancel'] as const)(
+ 'continues every discovery phase after a root %s failure',
+ async (failurePhase) => {
+ const failure = new Error(`root ${failurePhase} unavailable`)
+ const payload = { workflowId: 'workflow-1', executionId: 'execution-1' }
+ const cancelled = new Set()
+ if (failurePhase === 'receipt') {
+ dbChainMockFns.limit.mockRejectedValueOnce(failure)
+ } else {
+ queueTableRows(idempotencyKey, [{ result: { runId: 'run_root' } }])
+ }
+ mockRetrieve.mockImplementation(async (id: string) => {
+ if (id === 'run_root' && failurePhase === 'retrieve') throw failure
+ return { id, taskIdentifier: 'workflow-execution', status: 'QUEUED', payload }
+ })
+ mockCancel.mockImplementation(async (id: string) => {
+ if (id === 'run_root') throw failure
+ cancelled.add(id)
+ })
+ mockList
+ .mockReturnValueOnce(
+ createListPage([
+ { id: 'tagged', tags: ['workflowId:workflow-1', 'executionId:execution-1'] },
+ ])
+ )
+ .mockReturnValueOnce(
+ createListPage([{ id: 'legacy-tagged', tags: ['workflowId:workflow-1'] }])
+ )
+ .mockReturnValueOnce(createListPage([{ id: 'legacy-untagged', tags: [] }]))
+
+ await expect(
+ new TriggerDevJobQueue().cancelByExecution(
+ {
+ ...payload,
+ rootJobId: 'workflow-execution:execution-1',
+ },
+ 'standalone'
+ )
+ ).rejects.toBe(failure)
+ expect(cancelled).toEqual(new Set(['tagged', 'legacy-tagged', 'legacy-untagged']))
+ }
+ )
+
it('cancels legacy workflow-tagged runs only after payload verification', async () => {
mockList.mockReturnValueOnce(createListPage([])).mockReturnValueOnce(
createListPage([
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
index e65f04f9efb..e2f13aba5c9 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
@@ -1,9 +1,12 @@
+import { db } from '@sim/db'
+import { idempotencyKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { taskContext } from '@trigger.dev/core/v3'
import { ApiError, runs, type TriggerOptions, tasks } from '@trigger.dev/sdk'
+import { eq } from 'drizzle-orm'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import {
AsyncJobEnqueueError,
@@ -78,7 +81,36 @@ async function retrieveRunById(jobId: string): Promise {
}
}
-/** Resolves a caller-chosen job id through the `jobId:` tag set at enqueue. */
+/**
+ * Retains Trigger's accepted run ID before enqueue can return success. The
+ * idempotency result table shares receipts across app processes; its ordinary
+ * retention bounds storage, with tag lookup retained for older jobs.
+ */
+async function storeRunReceipt(jobId: string, runId: string): Promise {
+ const result = { runId }
+ await db
+ .insert(idempotencyKey)
+ .values({ key: `trigger-job:${jobId}`, result })
+ .onConflictDoUpdate({
+ target: idempotencyKey.key,
+ set: { result, createdAt: new Date() },
+ })
+}
+
+/** Reads accepted run IDs without depending on Trigger's asynchronous tag index. */
+async function retrieveRunByReceipt(jobId: string): Promise {
+ const [receipt] = await db
+ .select({ result: idempotencyKey.result })
+ .from(idempotencyKey)
+ .where(eq(idempotencyKey.key, `trigger-job:${jobId}`))
+ .limit(1)
+ const result = receipt?.result
+ return isRecordLike(result) && typeof result.runId === 'string'
+ ? retrieveRunById(result.runId)
+ : null
+}
+
+/** Resolves legacy or expired receipts through the `jobId:` tag set at enqueue. */
async function retrieveRunByJobIdTag(jobId: string): Promise {
for await (const candidate of runs.list({ tag: `jobId:${jobId}`, limit: 1 })) {
return runs.retrieve(candidate.id)
@@ -331,6 +363,18 @@ export class TriggerDevJobQueue implements JobQueueBackend {
throw classifyTriggerEnqueueError(error)
}
+ if (options?.jobId) {
+ try {
+ await storeRunReceipt(options.jobId, handle.id)
+ } catch (error) {
+ throw new AsyncJobEnqueueError('Trigger run accepted but its receipt could not be stored', {
+ acceptance: 'unknown',
+ retryable: true,
+ cause: error,
+ })
+ }
+ }
+
logger.debug('Enqueued job via trigger.dev', { jobId: handle.id, type, taskId, tags })
return handle.id
}
@@ -417,7 +461,10 @@ export class TriggerDevJobQueue implements JobQueueBackend {
async getJob(jobId: string): Promise {
try {
- const run = (await retrieveRunById(jobId)) ?? (await retrieveRunByJobIdTag(jobId))
+ const run =
+ (await retrieveRunById(jobId)) ??
+ (jobId.startsWith(TRIGGER_RUN_ID_PREFIX) ? null : await retrieveRunByReceipt(jobId)) ??
+ (await retrieveRunByJobIdTag(jobId))
if (!run) {
logger.debug('Job not found in trigger.dev', { jobId })
return null
@@ -492,7 +539,9 @@ export class TriggerDevJobQueue implements JobQueueBackend {
const executionTag = buildExecutionTag(binding.executionId)
const workflowTag = buildWorkflowTag(binding.workflowId)
const allowedTaskIdentifiers = EXECUTION_JOB_TYPES_BY_CANCELLATION_SCOPE[scope]
+ let cancelledRootRunId: string | undefined
const isAllowedTask = (run: CancellationListRun) =>
+ run.id !== cancelledRootRunId &&
(allowedTaskIdentifiers as readonly string[]).includes(run.taskIdentifier)
const cutoff = new Date(Date.now() - JOB_PENDING_RETENTION_HOURS * 60 * 60 * 1000)
const state: CancellationScanState = {
@@ -501,6 +550,24 @@ export class TriggerDevJobQueue implements JobQueueBackend {
}
try {
+ if (binding.rootJobId) {
+ try {
+ const root = await this.getJob(binding.rootJobId)
+ if (
+ root &&
+ (allowedTaskIdentifiers as readonly string[]).includes(root.type) &&
+ (root.status === JOB_STATUS.PENDING || root.status === JOB_STATUS.PROCESSING) &&
+ payloadMatchesExecution(root.payload, binding)
+ ) {
+ await this.cancelJob(root.id)
+ cancelledRootRunId = root.id
+ state.cancelledJobs += 1
+ }
+ } catch (error) {
+ recordCancellationCandidateFailure(state, error)
+ }
+ }
+
await scanAndCancelTriggerRuns({
binding,
cancelJob: (jobId) => this.cancelJob(jobId),
diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts
index 1cbecd49de0..d3c893cd54a 100644
--- a/apps/sim/lib/core/async-jobs/types.ts
+++ b/apps/sim/lib/core/async-jobs/types.ts
@@ -163,6 +163,8 @@ export interface EnqueueOptions {
export interface ExecutionJobBinding {
workflowId: string
executionId: string
+ /** Known root job identity; cancellation must still verify workflow, execution, and scope. */
+ rootJobId?: string
}
export type ExecutionJobCancellationScope = 'standalone' | 'resume'
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 0beda040bd2..eb3975a08f8 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -312,6 +312,7 @@ export const env = createEnv({
// Monitoring & Analytics
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
+ FREEBUFF_API_KEY: z.string().min(1).optional(), // Freebuff Ads key for server-side conversion postbacks (unset disables them)
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics.
@@ -580,6 +581,9 @@ export const env = createEnv({
INSTAGRAM_CLIENT_SECRET: z.string().optional(), // Instagram App Secret (Business Login)
SHOPIFY_CLIENT_ID: z.string().optional(), // Shopify OAuth client ID
SHOPIFY_CLIENT_SECRET: z.string().optional(), // Shopify OAuth client secret
+ ZOOM_SEARCH: z.boolean().optional(),
+ ZOOM_MCP_CLIENT_ID: z.string().optional(), // Zoom Search MCP OAuth client ID
+ ZOOM_MCP_CLIENT_SECRET: z.string().optional(), // Zoom Search MCP OAuth client secret
ZOOM_CLIENT_ID: z.string().optional(), // Zoom OAuth client ID
ZOOM_CLIENT_SECRET: z.string().optional(), // Zoom OAuth client secret
WORDPRESS_CLIENT_ID: z.string().optional(), // WordPress.com OAuth client ID
@@ -594,6 +598,7 @@ export const env = createEnv({
AGENTMAIL_API_KEY: z.string().min(1).optional(), // AgentMail API key for mothership email inbox
AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to)
MSHIP_PLAN_MODE: z.boolean().optional(),
+ DASHBOARDS: z.boolean().optional(),
MSHIP_MODEL_SELECTOR: z.boolean().optional(),
SIM_SEARCH_LIVE: z.boolean().optional(), // Query connected providers directly; false preserves indexed search
INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements)
diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts
index c0cfd64730c..ad1f84dde1d 100644
--- a/apps/sim/lib/core/config/feature-flags.test.ts
+++ b/apps/sim/lib/core/config/feature-flags.test.ts
@@ -39,6 +39,7 @@ const envRef = mockEnvObject
setEnv({
APPCONFIG_APPLICATION: 'sim-staging',
APPCONFIG_ENVIRONMENT: 'staging',
+ DASHBOARDS: undefined,
TABLES_V2_API: undefined,
TABLE_ROW_TTL: undefined,
MSHIP_MODEL_SELECTOR: undefined,
@@ -69,6 +70,22 @@ describe('getFeatureFlags', () => {
beforeEach(() => {
setEnvFlags({ isAppConfigEnabled: false })
envRef.AGENT_MEMORY_HISTORY = undefined
+ envRef.DASHBOARDS = undefined
+ })
+
+ it('rolls dashboards out globally or by organization and defaults off locally', async () => {
+ expect(await isFeatureEnabled('dashboards')).toBe(false)
+ envRef.DASHBOARDS = true
+ expect(await isFeatureEnabled('dashboards')).toBe(true)
+ withAppConfig({ dashboards: { orgIds: ['org-a'] } })
+ expect(await isFeatureEnabled('dashboards', { orgId: 'org-a' })).toBe(true)
+ expect(await isFeatureEnabled('dashboards', { orgId: 'org-b' })).toBe(false)
+ expect(await isFeatureEnabled('dashboards')).toBe(false)
+ withAppConfig({ dashboards: { enabled: true } })
+ expect(await isFeatureEnabled('dashboards', { orgId: 'org-b' })).toBe(true)
+ withAppConfig({ dashboards: { enabled: false } })
+ expect(await isFeatureEnabled('dashboards', { orgId: 'org-a' })).toBe(false)
+ envRef.DASHBOARDS = undefined
})
it('rolls Agent history out by workspace and retains a global capture switch', async () => {
diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts
index 91c105338c9..83925454c75 100644
--- a/apps/sim/lib/core/config/feature-flags.ts
+++ b/apps/sim/lib/core/config/feature-flags.ts
@@ -46,6 +46,11 @@ interface FeatureFlagDefinition {
/** The single registry of known flags. To add a flag, add one entry here. */
const FEATURE_FLAGS = {
+ dashboards: {
+ description:
+ 'Enable dashboard resources, rendering, analytics, and Mothership authoring. Supports global and organization rollout; disabled by default.',
+ fallback: 'DASHBOARDS',
+ },
'mothership-model-selector': {
description:
'Show the Mothership model selector, model-specific effort levels, and Fast for supported ' +
@@ -63,6 +68,11 @@ const FEATURE_FLAGS = {
'Capture durable Workflow Agent tool history and continue existing retries. Supports workspace rollout targeting; version-aware memory storage remains active when capture is disabled.',
fallback: 'AGENT_MEMORY_HISTORY',
},
+ 'zoom-search': {
+ description:
+ 'Enable Zoom Search setup, personal authorization and retrieval. Organization targeting only; disabled by default. Standard workflow Zoom OAuth is unchanged.',
+ fallback: 'ZOOM_SEARCH',
+ },
'slack-search-shared-app': {
description:
'Enable the official shared Slack app for existing Search customers. Supports orgId ' +
diff --git a/apps/sim/lib/core/redis/byte-budget.server.ts b/apps/sim/lib/core/redis/byte-budget.server.ts
index 5728d69960a..c8554ffb400 100644
--- a/apps/sim/lib/core/redis/byte-budget.server.ts
+++ b/apps/sim/lib/core/redis/byte-budget.server.ts
@@ -8,9 +8,12 @@ import type { Logger } from '@sim/logger'
* budget the execution event buffer has enforced since it was written, which the
* copilot stream buffer now shares rather than inventing a bound of its own.
*
- * A quota is the right bound for a buffer whose contents must stay contiguous: the
- * copilot replay chain and an execution's event history are read from a cursor, so
- * the write that would breach the ceiling is refused and the buffer stops growing.
+ * A quota is the right bound for a buffer whose contents must stay contiguous: an
+ * execution's event history is read from a cursor, so the write that would breach
+ * the ceiling is refused and the buffer stops growing. The copilot replay ring trims
+ * its oldest events by bytes below its ceiling instead, refunding what it drops, so a
+ * long run slides rather than refuses; a reader behind the trim is re-synced from the
+ * worker's run log, and ends with a replay gap only when that log cannot serve it.
* A live-update feed is bounded differently — see `lib/realtime/event-log.ts`, whose
* readers already handle a prune by refetching, so it drops oldest-first instead.
*
@@ -67,6 +70,13 @@ export interface RedisBudgetLimits {
* already dropped and eventually pin the user at their ceiling until they went a full
* window without writing. User counters therefore get a fixed window: set on
* creation, never extended.
+ *
+ * Because a trim refunds both counters, the user counter bounds bytes HELD across a
+ * user's owners, not bytes written per hour: a single long copilot stream holds at most
+ * its ring's byte target however much it writes. Bytes of owners that ended stay counted
+ * until the window lapses. The reset is not reconciled with what is still held, so
+ * right after it a user can hold up to about twice the cap: the bytes the lapsed
+ * window counted plus a fresh cap.
*/
const REDIS_BUDGET_TTL_SECONDS = 60 * 60
@@ -79,8 +89,8 @@ const LIMITS: Record
},
/**
* A copilot turn streams text and tool frames, not payloads — a single frame past
- * 1 MB is already pathological. The owner ceiling is what a long agentic session
- * may retain for replay across its whole hour.
+ * 1 MB is already pathological. The owner ceiling bounds what one stream retains
+ * for replay; the ring trims its oldest events to stay below it.
*/
copilot_stream: {
maxSingleWriteBytes: 1 * 1024 * 1024,
diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts
index 9c6b99370ab..1614c81b756 100644
--- a/apps/sim/lib/core/security/csp.ts
+++ b/apps/sim/lib/core/security/csp.ts
@@ -114,6 +114,8 @@ const STATIC_SCRIPT_SRC = [
// X (Twitter) conversion pixel (landing pages) — the base code injects
// uwt.js as a