diff --git a/.claude/rules/constitution.md b/.claude/rules/constitution.md index 62dd7df3c56..565dee4b877 100644 --- a/.claude/rules/constitution.md +++ b/.claude/rules/constitution.md @@ -1,3 +1,7 @@ +--- +description: Sim product language, positioning, and tone guidelines +--- + # Sim — Language & Positioning When editing user-facing copy (landing pages, docs, metadata, marketing), follow these rules. diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index cab88b47f67..f02ff3946e5 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -1,4 +1,5 @@ --- +description: EMCN component library patterns paths: - "packages/emcn/**" --- diff --git a/.claude/rules/global.md b/.claude/rules/global.md index 8ae6e0e874c..90852e76206 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -1,3 +1,7 @@ +--- +description: Global coding standards that apply to all files +--- + # Global Standards ## Logging diff --git a/.claude/rules/landing-seo-geo.md b/.claude/rules/landing-seo-geo.md index 4f6ec536ad0..51c5402262a 100644 --- a/.claude/rules/landing-seo-geo.md +++ b/.claude/rules/landing-seo-geo.md @@ -1,4 +1,5 @@ --- +description: SEO and GEO guidelines for the landing page paths: - "apps/sim/app/(landing)/**/*.tsx" - "apps/sim/content/**/*.mdx" diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index d950851a3f1..95a59198851 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -1,4 +1,5 @@ --- +description: Core architecture principles for the Sim app paths: - "apps/sim/**" --- diff --git a/.claude/rules/sim-caching.md b/.claude/rules/sim-caching.md index fced410b448..28a4f884234 100644 --- a/.claude/rules/sim-caching.md +++ b/.claude/rules/sim-caching.md @@ -1,4 +1,5 @@ --- +description: In-process caching with lru-cache and a ceiling, never hand-rolled TTL maps paths: - "apps/sim/lib/**/*.ts" - "apps/sim/providers/**/*.ts" diff --git a/.claude/rules/sim-components.md b/.claude/rules/sim-components.md index 183fbaac822..f435b20c1a8 100644 --- a/.claude/rules/sim-components.md +++ b/.claude/rules/sim-components.md @@ -1,4 +1,5 @@ --- +description: Component patterns and structure for React components paths: - "apps/sim/**/*.tsx" --- diff --git a/.claude/rules/sim-hooks.md b/.claude/rules/sim-hooks.md index 36b96827c82..a4ff58fdbca 100644 --- a/.claude/rules/sim-hooks.md +++ b/.claude/rules/sim-hooks.md @@ -1,4 +1,5 @@ --- +description: Custom hook patterns and best practices paths: - "apps/sim/**/use-*.ts" - "apps/sim/**/hooks/**/*.ts" diff --git a/.claude/rules/sim-imports.md b/.claude/rules/sim-imports.md index 0449d96e11e..3aeafa0dd2e 100644 --- a/.claude/rules/sim-imports.md +++ b/.claude/rules/sim-imports.md @@ -1,4 +1,5 @@ --- +description: Import patterns for the Sim application paths: - "apps/sim/**/*.ts" - "apps/sim/**/*.tsx" diff --git a/.claude/rules/sim-integrations.md b/.claude/rules/sim-integrations.md index 34231a900b9..c4729747596 100644 --- a/.claude/rules/sim-integrations.md +++ b/.claude/rules/sim-integrations.md @@ -1,4 +1,5 @@ --- +description: Adding new integrations (tools, blocks, triggers) paths: - "apps/sim/tools/**" - "apps/sim/blocks/**" diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md index cad6c2104b3..9b6d6a87147 100644 --- a/.claude/rules/sim-list-ordering.md +++ b/.claude/rules/sim-list-ordering.md @@ -1,4 +1,5 @@ --- +description: List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action paths: - "apps/sim/app/**/*.tsx" - "apps/sim/ee/**/*.tsx" diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index a71ee448dde..f2600e4e264 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -1,4 +1,5 @@ --- +description: React Query patterns for the Sim application paths: - "apps/sim/hooks/queries/**/*.ts" --- diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md index a78c2e984e4..a9bfeaa7f63 100644 --- a/.claude/rules/sim-react-performance.md +++ b/.claude/rules/sim-react-performance.md @@ -1,3 +1,7 @@ +--- +description: Behavior-preserving React render-performance idioms +--- + # React & Render Performance Behavior-preserving performance idioms for components, hooks, and hot render paths. These are safe defaults — apply them freely. For the render-causing *effect/state* anti-patterns (derived state in effects, effect chains, state synced to a prop), use the dedicated skills: `/you-might-not-need-an-effect`, `/you-might-not-need-state`, `/you-might-not-need-a-memo`, `/you-might-not-need-a-callback`. Those refactors change render timing — verify them against the running UI, never mass-apply blind. diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 9893028c352..aaf692544a1 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -1,4 +1,5 @@ --- +description: Settings page layout and SettingsPanel design-system compliance paths: - "apps/sim/app/workspace/*/settings/**" - "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**" diff --git a/.claude/rules/sim-stores.md b/.claude/rules/sim-stores.md index 273c394fbce..d44078e84bc 100644 --- a/.claude/rules/sim-stores.md +++ b/.claude/rules/sim-stores.md @@ -1,4 +1,5 @@ --- +description: Zustand store patterns paths: - "apps/sim/**/store.ts" - "apps/sim/**/stores/**/*.ts" diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 61960c9eb5c..48d9086a481 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -1,4 +1,5 @@ --- +description: Tailwind CSS and styling conventions paths: - "apps/sim/**/*.tsx" - "apps/sim/**/*.css" diff --git a/.claude/rules/sim-testing.md b/.claude/rules/sim-testing.md new file mode 100644 index 00000000000..13c9044e594 --- /dev/null +++ b/.claude/rules/sim-testing.md @@ -0,0 +1,258 @@ +--- +description: Testing patterns with Vitest and @sim/testing +paths: + - "apps/sim/**/*.test.ts" + - "apps/sim/**/*.test.tsx" +--- + +# Testing Patterns + +Use Vitest. Test files: `feature.ts` → `feature.test.ts` + +## Global Mocks (vitest.setup.ts) + +These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: + +- `@sim/db` → `databaseMock` +- `@sim/db/schema` → `schemaMock` +- `drizzle-orm` → `drizzleOrmMock` +- `@sim/logger` → `loggerMock` +- `@/lib/auth` → `authMock` +- `@/lib/auth/hybrid` → `hybridAuthMock` (with default session-delegating behavior) +- `@/lib/core/utils/request` → `requestUtilsMock` +- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` +- `@/blocks/registry` +- `@trigger.dev/sdk` +- `@sim/platform-authz/workflow` → `workflowAuthzMock` + +## Structure + +```typescript +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +import { GET, POST } from '@/app/api/my-route/route' + +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('returns data', async () => { + const req = createMockRequest('GET') + const res = await GET(req) + expect(res.status).toBe(200) + }) +}) +``` + +## Performance Rules (Critical) + +### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` + +This is the #1 cause of slow tests. It forces complete module re-evaluation per test. + +```typescript +// BAD — forces module re-evaluation every test (~50-100ms each) +beforeEach(() => { + vi.resetModules() + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) +}) +it('test', async () => { + const { GET } = await import('./route') // slow dynamic import +}) + +// GOOD — module loaded once, mocks reconfigured per test (~1ms each) +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +import { GET } from '@/app/api/my-route/route' + +beforeEach(() => { vi.clearAllMocks() }) +it('test', () => { + mockGetSession.mockResolvedValue({ user: { id: '1' } }) +}) +``` + +**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. + +### NEVER use `vi.importActual()` + +This defeats the purpose of mocking by loading the real module and all its dependencies. + +```typescript +// BAD — loads real module + all transitive deps +vi.mock('@/lib/workspaces/utils', async () => { + const actual = await vi.importActual('@/lib/workspaces/utils') + return { ...actual, myFn: vi.fn() } +}) + +// GOOD — mock everything, only implement what tests need +vi.mock('@/lib/workspaces/utils', () => ({ + myFn: vi.fn(), + otherFn: vi.fn(), +})) +``` + +### Mock heavy transitive dependencies + +If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: + +```typescript +vi.mock('@/blocks', () => ({ + getBlock: () => null, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) +``` + +### Use `@vitest-environment node` unless DOM is needed + +Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. + +### Avoid real timers in tests + +```typescript +// BAD +await new Promise(r => setTimeout(r, 500)) + +// GOOD — use minimal delays or fake timers +await new Promise(r => setTimeout(r, 1)) +// or +vi.useFakeTimers() +``` + +## Centralized Mocks (prefer over local declarations) + +`@sim/testing` exports ready-to-use mock modules for common dependencies. Import and pass directly to `vi.mock()` — no `vi.hoisted()` boilerplate needed. Each paired `*MockFns` object exposes the underlying `vi.fn()`s for per-test overrides. + +| Module mocked | Import | Factory form | +|---|---|---| +| `@/app/api/auth/oauth/utils` | `authOAuthUtilsMock`, `authOAuthUtilsMockFns` | `vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)` | +| `@/app/api/knowledge/utils` | `knowledgeApiUtilsMock`, `knowledgeApiUtilsMockFns` | `vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)` | +| `@/app/api/workflows/utils` | `workflowsApiUtilsMock`, `workflowsApiUtilsMockFns` | `vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)` | +| `@sim/audit` | `auditMock`, `auditMockFns` | `vi.mock('@sim/audit', () => auditMock)` | +| `@/lib/auth` | `authMock`, `authMockFns` | `vi.mock('@/lib/auth', () => authMock)` | +| `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | +| `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | +| `@/lib/core/config/env` | `envMock`, `createEnvMock(overrides)` | `vi.mock('@/lib/core/config/env', () => envMock)` | +| `@/lib/core/config/env-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/env-flags', () => featureFlagsMock)` | +| `@/lib/core/config/redis` | `redisConfigMock`, `redisConfigMockFns` | `vi.mock('@/lib/core/config/redis', () => redisConfigMock)` | +| `@/lib/core/security/encryption` | `encryptionMock`, `encryptionMockFns` | `vi.mock('@/lib/core/security/encryption', () => encryptionMock)` | +| `@/lib/core/security/input-validation.server` | `inputValidationMock`, `inputValidationMockFns` | `vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)` | +| `@/lib/core/utils/request` | `requestUtilsMock`, `requestUtilsMockFns` | `vi.mock('@/lib/core/utils/request', () => requestUtilsMock)` | +| `@/lib/core/utils/urls` | `urlsMock`, `urlsMockFns` | `vi.mock('@/lib/core/utils/urls', () => urlsMock)` | +| `@/lib/execution/preprocessing` | `executionPreprocessingMock`, `executionPreprocessingMockFns` | `vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)` | +| `@/lib/logs/execution/logging-session` | `loggingSessionMock`, `loggingSessionMockFns`, `LoggingSessionMock` | `vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)` | +| `@/lib/workflows/orchestration` | `workflowsOrchestrationMock`, `workflowsOrchestrationMockFns` | `vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)` | +| `@/lib/workflows/persistence/utils` | `workflowsPersistenceUtilsMock`, `workflowsPersistenceUtilsMockFns` | `vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)` | +| `@/lib/workflows/utils` | `workflowsUtilsMock`, `workflowsUtilsMockFns` | `vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)` | +| `@/lib/workspaces/permissions/utils` | `permissionsMock`, `permissionsMockFns` | `vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)` | +| `@sim/db/schema` | `schemaMock` | `vi.mock('@sim/db/schema', () => schemaMock)` | + +### Auth mocking (API routes) + +```typescript +import { authMock, authMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/auth', () => authMock) + +import { GET } from '@/app/api/my-route/route' + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) +}) +``` + +Only define a local `vi.mock('@/lib/auth', ...)` if the module under test consumes exports outside the centralized shape (e.g., `auth.api.verifyOneTimeToken`, `auth.api.resetPassword`). + +### Hybrid auth mocking + +```typescript +import { hybridAuthMock, hybridAuthMockFns } from '@sim/testing' + +vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) + +// In tests: +hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, userId: 'user-1', authType: 'session', +}) +``` + +### Database chain mocking + +Use the centralized `dbChainMock` + `dbChainMockFns` helpers — no `vi.hoisted()` or chain-wiring boilerplate needed. + +```typescript +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' + +vi.mock('@sim/db', () => dbChainMock) +// Spread for custom exports: vi.mock('@sim/db', () => ({ ...dbChainMock, myTable: {...} })) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() // only needed if tests use permanent (non-`Once`) overrides +}) + +it('reads a row', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: '1', name: 'test' }]) + // exercise code that hits db.select().from().where().limit() + expect(dbChainMockFns.where).toHaveBeenCalled() +}) +``` + +**Default chains supported:** +- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()/for()` +- `insert() → values() → returning()/onConflictDoUpdate()/onConflictDoNothing()` +- `update() → set() → where() → limit()/orderBy()/returning()/for()` +- `delete() → where() → limit()/orderBy()/returning()/for()` +- `db.execute()` resolves `[]` +- `db.transaction(cb)` calls cb with `dbChainMock.db` + +`.for('update')` (Postgres row-level locking) is supported on `where` builders. It returns a thenable with `.limit` / `.orderBy` / `.returning` / `.groupBy` attached, so both `await .where().for('update')` (terminal) and `await .where().for('update').limit(1)` (chained) work. Override the terminal result with `dbChainMockFns.for.mockResolvedValueOnce([...])`; for the chained form, mock the downstream terminal (e.g. `dbChainMockFns.limit.mockResolvedValueOnce([...])`). + +All terminals default to `Promise.resolve([])`. Override per-test with `dbChainMockFns..mockResolvedValueOnce(...)`. Use `resetDbChainMock()` in `beforeEach` only when tests replace wiring with `.mockReturnValue` / `.mockResolvedValue` (permanent); tests using only `...Once` variants don't need it. + +## @sim/testing Package + +Always prefer over local test data. + +| Category | Utilities | +|----------|-----------| +| **Module mocks** | See "Centralized Mocks" table above | +| **Logger helpers** | `loggerMock`, `createMockLogger()`, `getLoggerCalls()`, `clearLoggerMocks()` | +| **Database helpers** | `databaseMock`, `drizzleOrmMock`, `createMockDb()`, `createMockSql()`, `createMockSqlOperators()` | +| **Fetch helpers** | `setupGlobalFetchMock()`, `createMockFetch()`, `createMockResponse()`, `mockFetchError()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +| **Requests** | `createMockRequest()`, `createMockFormDataRequest()` | + +## Rules Summary + +1. `@vitest-environment node` unless DOM is required +2. Prefer centralized mocks from `@sim/testing` (see table above) over local `vi.hoisted()` + `vi.mock()` boilerplate +3. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports +4. `vi.mock()` calls before importing mocked modules +5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` +6. No `vi.importActual()` — mock everything explicitly +7. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +8. Use absolute imports in test files +9. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` diff --git a/.claude/rules/sim-typescript.md b/.claude/rules/sim-typescript.md index 526eefd6499..e381c7d1bbf 100644 --- a/.claude/rules/sim-typescript.md +++ b/.claude/rules/sim-typescript.md @@ -1,4 +1,5 @@ --- +description: TypeScript conventions and type safety paths: - "apps/sim/**/*.ts" - "apps/sim/**/*.tsx" diff --git a/.claude/rules/sim-ui-copy.md b/.claude/rules/sim-ui-copy.md index 951e4a15367..b676483f41f 100644 --- a/.claude/rules/sim-ui-copy.md +++ b/.claude/rules/sim-ui-copy.md @@ -1,4 +1,5 @@ --- +description: UI copy conventions — no default subtitles or helper text under headings, labels, cards, or settings paths: - "apps/sim/**/*.tsx" - "apps/sim/components/emcn/**" diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index 6790bc1b56d..69d8c3aec92 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -1,4 +1,5 @@ --- +description: Shareable client view-state lives in the URL via nuqs paths: - "apps/sim/app/**/*.tsx" - "apps/sim/app/**/*.ts" diff --git a/.cursor/rules/constitution.mdc b/.cursor/rules/constitution.mdc index dbce52e5298..9e9a50d2b2b 100644 --- a/.cursor/rules/constitution.mdc +++ b/.cursor/rules/constitution.mdc @@ -1,8 +1,10 @@ --- -description: Sim product language, positioning, and tone guidelines -globs: ["apps/sim/app/(landing)/**", "apps/docs/**", "apps/sim/app/manifest.ts", "apps/sim/app/sitemap.ts", "apps/sim/app/robots.ts", "apps/sim/app/llms.txt/**", "apps/sim/app/llms-full.txt/**", "apps/sim/app/(landing)/**/structured-data*", "apps/docs/**/structured-data*", "**/metadata*", "**/seo*"] +description: "Sim product language, positioning, and tone guidelines" +alwaysApply: true --- + + # Sim — Language & Positioning When editing user-facing copy (landing pages, docs, metadata, marketing), follow these rules. @@ -28,7 +30,8 @@ Sim is the **AI workspace** where teams build and run AI agents. Not a workflow | The product | "AI workspace" | "workflow tool", "automation platform", "agent framework" | | Building | "build agents", "create agents" | "create workflows" (unless describing the workflow module specifically) | | Visual builder | "workflow builder" or "visual builder" | "canvas", "graph editor" | -| Mothership | "Mothership" (capitalized) | "chat", "AI assistant", "copilot" | +| The agent | "Sim" — you talk to Sim | "Mothership", "copilot", "AI assistant" | +| The chat surface | "Chat" (capitalized, the module) | "Mothership", "copilot" | | Deployment | "deploy", "ship" | "publish", "activate" | | Audience | "teams", "builders" | "users", "customers" (in marketing copy) | | What agents do | "automate real work" | "automate tasks", "automate workflows" | @@ -55,7 +58,7 @@ When describing Sim, always lead with the most differentiated claim: | Module | One-liner | |--------|-----------| -| **Mothership** | Your AI command center. Build and manage everything in natural language. | +| **Chat** | Your AI command center. Talk to Sim — build and manage everything in natural language. | | **Workflows** | The visual builder. Connect blocks, models, and integrations into agent logic. | | **Knowledge Base** | Your agents' memory. Upload docs, sync sources, build vector databases. | | **Tables** | A database, built in. Store, query, and wire structured data into agent runs. | @@ -70,7 +73,8 @@ When describing Sim, always lead with the most differentiated claim: - Never promise unshipped features - Never use jargon ("RAG", "vector database", "MCP") without plain-English explanation on public pages - Avoid "agentic workforce" as a primary term — use "AI agents" +- Never say "Mothership" or "copilot" — the agent is "Sim", the surface is "Chat" (in run logs the trigger reads "Sim agent") ## Vision -Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Mothership is another. The workspace is the constant; the interface adapts. +Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Chat is another. The workspace is the constant; the interface adapts. diff --git a/.cursor/rules/emcn-components.mdc b/.cursor/rules/emcn-components.mdc index e9307f6dccf..1ac5fc577f8 100644 --- a/.cursor/rules/emcn-components.mdc +++ b/.cursor/rules/emcn-components.mdc @@ -1,16 +1,19 @@ --- -description: EMCN component library patterns +description: "EMCN component library patterns" globs: ["packages/emcn/**"] --- + + + # EMCN Components -Import from `@sim/emcn`, never from subpaths (except CSS files). The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`/`ChipDropdown`, `Switch`→`ChipSwitch`, date field→`ChipDatePicker`). For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover). +Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from the `@sim/emcn/icons` subpath, and CSS modules from their file path. Never deep-import other component subpaths. The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`/`ChipDropdown`, `Switch`→`ChipSwitch`, date field→`ChipDatePicker`). For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover). ## Chip chrome — single source of truth Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources: -- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, and `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. +- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells), and the row-state pair `chipHoverSurfaceClass` / `chipActiveSurfaceClass` (hover vs. selected — mutually exclusive, so a selected row holds its surface through hover; every hand-rolled row imports these rather than restating the literals). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. - **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity. Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border-1` border. @@ -19,16 +22,30 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items ## Component catalogue -- **`Chip` / `ChipLink`** — the pill button (`, + SlackSearchActions: () => Return to Slack, +})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection', + () => ({ + SearchIntegrationConnection: (props: unknown) => { + mocks.request(props) + return Requested connection + }, + }) +) +vi.mock('@/hooks/queries/personal-search-integrations', () => ({ + usePersonalSearchIntegrations: mocks.slackInventory, })) vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.integrations, })) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: mocks.availability, -})) -vi.mock('nuqs', () => ({ - useQueryState: () => [null, mocks.setSource], - parseAsString: { withOptions: () => ({}) }, - parseAsStringLiteral: () => ({ withOptions: () => ({}) }), -})) +vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: mocks.availability })) vi.mock('@/app/o/[organizationId]/components/organization-page', () => ({ OrganizationPage: ({ action, children }: { action?: ReactNode; children?: ReactNode }) => ( <> @@ -46,9 +71,7 @@ vi.mock('@/app/o/[organizationId]/components/organization-page', () => ({ })) vi.mock( '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters', - () => ({ - useOrganizationPageFilters: mocks.filters, - }) + () => ({ useOrganizationPageFilters: mocks.filters }) ) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, @@ -56,20 +79,41 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ IntegrationTile: () => null, })) +vi.mock('@/app/o/[organizationId]/integrations/disconnect-account-menu', () => ({ + DisconnectAccountMenu: (props: { + integrationName: string + accounts: { credentialId: string }[] + actions?: RowAction[] + }) => { + mocks.accountMenu(props) + const actions = [ + ...(props.actions ?? []), + ...props.accounts.map((account) => ({ + label: `Disconnect ${account.credentialId}`, + onSelect: vi.fn(), + })), + ] + return actions.length ? ( + + ) : null + }, +})) vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchSources: mocks.sources, useSearchSourceOverview: mocks.overview, })) vi.mock('@/hooks/use-member-enrollment', () => ({ + enrollmentActionLabel: (membership: string, waiting: boolean) => + waiting ? 'Open again' : membership === 'needs_reauth' ? 'Reconnect' : 'Connect', CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), useMemberEnrollment: (options: unknown) => { mocks.enrollment(options) return { connect: mocks.connect, - connectSearchSource: mocks.connect, + connectSearchSource: mocks.connectSearchSource, isAwaiting: () => false, + isAwaitingSource: () => false, isPending: false, - error: mocks.enrollmentError, setupConnector: mocks.setupConnector, closeSetup: vi.fn(), } @@ -80,16 +124,25 @@ vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnRouter: () => undefined, })) -import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' +import { MemberIntegrationsList } from '@/app/o/[organizationId]/integrations/member-integrations-list' +import { + type RowAction, + RowActionsMenu, +} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' const scope = { kind: 'organization', organizationId: 'organization-a' } as const +const account = { + credentialId: 'account', + displayName: 'My work account', + status: 'active' as const, +} const memberSource: SearchSourceSummary = { knowledgeBaseId: 'search-index', - connectorId: 'member-source', + connectorId: 'source-a', connectorType: 'gmail', - sourceDescription: 'Gmail', + sourceDescription: 'Inbox', accessMode: 'members', availability: 'available', enabled: true, @@ -97,450 +150,821 @@ const memberSource: SearchSourceSummary = { lastSyncAt: null, hasSyncError: false, viewerDocumentCount: 0, + viewerFailedDocumentCount: 0, viewerEmailVerified: true, + viewerAccounts: [], connectionRequired: true, viewerMembership: 'not_enrolled', + approved: true, } const centralSource: SearchSourceSummary = { ...memberSource, - connectorId: 'central-source', + connectorId: 'central', connectorType: 'google_drive', - sourceDescription: 'Engineering', + sourceDescription: 'Shared Drive', accessMode: 'admin', - viewerDocumentCount: 4, connectionRequired: false, viewerMembership: null, } -describe('organization integrations role and source paths', () => { - let root: Root - let container: HTMLDivElement +let root: Root +let container: HTMLDivElement +let rows: SearchSourceSummary[] +let queryOverrides: Record +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(toast, 'error').mockReturnValue('toast') + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.setupConnector = null + mocks.organizationAccounts.mockReturnValue({ + data: { credentialGroup: null, viewerAccounts: [] }, + isPending: false, + isError: false, + refetch: mocks.refetchAccounts, + }) + rows = [memberSource] + queryOverrides = {} + mocks.context.mockReturnValue({ + organization: { id: scope.organizationId }, + viewer: { isAdmin: false }, + searchAccess: { memberScoped: true, sourceMirrored: true }, + }) + mocks.filters.mockReturnValue({ search: '' }) + mocks.slackInventory.mockReturnValue({ + data: { available: [] }, + isPending: false, + isError: false, + }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.overview.mockReturnValue({ + data: { + providers: [{ connectorType: 'gmail', isSyncing: false }], + hasSearchableDocuments: false, + }, + isPending: false, + }) + mocks.availability.mockReturnValue({ + integrationAvailability: new Map(), + oauthServiceAvailability: new Map([ + ['google-email', true], + ['confluence', true], + ['jira', true], + ]), + isIntegrationAvailabilityReady: true, + integrationAvailabilityError: null, + }) + mocks.sources.mockImplementation( + (_scope: unknown, options: { enabled: boolean; connectorType?: string }) => ({ + data: options.enabled + ? rows.filter((row) => row.connectorType === options.connectorType) + : undefined, + isPending: false, + isError: false, + isFetching: false, + isFetchNextPageError: false, + hasNextPage: false, + fetchNextPage: mocks.nextPage, + refetch: mocks.refetch, + ...queryOverrides, + }) + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) +async function render(searchParams = '', element: ReactNode = ) { + await act(async () => + root.render( + + {element} + + ) + ) +} +function buttons(label: string) { + return Array.from(document.querySelectorAll('button')).filter( + (button) => button.textContent?.trim() === label + ) +} + +async function openMenu(name: string) { + const trigger = document.querySelector( + `[aria-label="${name} integration actions"]` + )! + await act(async () => + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) +} +function menuItem(label: string) { + return [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => item.textContent === label + )! +} + +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', + providerId: 'github-repositories', + groupId: 'accounts-group', + optionId: 'github-option', + displayName: 'My GitHub', + status: 'active' as const, + } + const githubGroup = { + id: 'accounts-group', + status: 'active', + options: [{ id: 'github-option', provider: 'github-repositories', status: 'active' }], + } beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(toast, 'error').mockReturnValue('toast-id') - mocks.enrollmentError = null - mocks.setupConnector = null - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - mocks.context.mockReturnValue({ - organization: { id: scope.organizationId }, - viewer: { isAdmin: false }, - searchAccess: { memberScoped: true, sourceMirrored: true }, + rows = ['repo-one', 'repo-two'].map((connectorId) => ({ + ...memberSource, + connectorId, + connectorType: 'github', + sourceDescription: connectorId, + })) + mocks.overview.mockReturnValue({ + data: { providers: [{ connectorType: 'github' }] }, + isPending: false, + }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'github', approved: true }], + isPending: false, }) - mocks.integrations.mockReturnValue({ data: [], isPending: false }) mocks.availability.mockReturnValue({ integrationAvailability: new Map(), - oauthServiceAvailability: new Map([ - ['google-email', true], - ['confluence', true], - ['jira', true], - ]), + oauthServiceAvailability: new Map([['github-repositories', true]]), isIntegrationAvailabilityReady: true, - integrationAvailabilityError: null, - isIntegrationAvailabilityFetching: false, - refetchIntegrationAvailability: mocks.refetchAvailability, }) - mocks.sources.mockReturnValue({ data: [memberSource, centralSource], isPending: false }) - mocks.overview.mockReturnValue({ - data: { - providers: [ - { connectorType: 'gmail', isSyncing: false }, - { connectorType: 'google_drive', isSyncing: false }, - ], - hasSearchableDocuments: false, - }, + mocks.organizationAccounts.mockReturnValue({ + data: { credentialGroup: githubGroup, viewerAccounts: [] }, isPending: false, + isError: false, + refetch: mocks.refetchAccounts, }) - mocks.filters.mockReturnValue({ tab: null, search: '', setSearch: vi.fn() }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) }) - afterEach(async () => { - await act(async () => root.unmount()) - vi.useRealTimers() - vi.restoreAllMocks() - container.remove() - vi.unstubAllGlobals() - }) - - async function render() { - await act(async () => root.render()) - } - - function buttons(label: string) { - return Array.from(document.querySelectorAll('button')).filter( - (button) => button.textContent?.trim() === label - ) - } - - it.each([OrganizationIntegrations, ConnectAccountOptions])( - 'shows connection errors in a toast without adding inline error text in %s', - async (Component) => { - const message = 'Choose the account matching your Sim email address.' - mocks.enrollmentError = message - await act(async () => root.render()) - const options = mocks.enrollment.mock.calls[0][0] as { - onConnectionError: (message: string) => void - } - act(() => options.onConnectionError(message)) - expect(toast.error).toHaveBeenCalledExactlyOnceWith(message) - expect(container.textContent).not.toContain(message) - await act(async () => root.render()) - expect(toast.error).toHaveBeenCalledOnce() - } - ) - - it('keeps source setup fields after a failure without duplicating the toast inside the modal', async () => { - const message = 'Connection unavailable' - mocks.enrollmentError = message - mocks.setupConnector = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira') ?? null - await render() - expect(document.querySelector('[role="dialog"]')).not.toBeNull() - expect(document.body.textContent).not.toContain(message) - }) - - it('uses the actual organization and only asks members to connect identity-dependent sources', async () => { + 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) => { await render() - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '' }) - expect(buttons('Add source')).toHaveLength(0) - expect(buttons('Manage')).toHaveLength(0) expect(buttons('Connect')).toHaveLength(1) - expect(document.body.textContent).not.toContain('Engineering') + expect(container.textContent).toContain('Connect once') await act(async () => buttons('Connect')[0].click()) - expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') + expect(mocks.connectOrganizationAccount).toHaveBeenCalledExactlyOnceWith( + { organizationId: scope.organizationId, optionId: 'github-option' }, + expect.any(Object) + ) + expectConnectionRedirect(mocks.connectOrganizationAccount.mock.calls[0][1].onSuccess, url) + expect(mocks.sources).not.toHaveBeenCalled() + expect(mocks.connect).not.toHaveBeenCalled() + expect(mocks.connectSearchSource).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.textContent).not.toContain('repo-one') }) - it('keeps Slack return actions alongside personal connection controls', async () => { - mocks.context.mockReturnValue({ - organization: { id: scope.organizationId }, - viewer: { isAdmin: true }, - searchAccess: { memberScoped: true, sourceMirrored: true }, + it('keeps one account row when an admin adds another repository', async () => { + mocks.organizationAccounts.mockReturnValue({ + data: { credentialGroup: githubGroup, viewerAccounts: [githubAccount] }, + isPending: false, }) - await act(async () => - root.render( - - ) + await render() + rows.push({ + ...rows[0], + connectorId: 'future-repository', + sourceDescription: 'future-repository', + }) + await render() + expect(document.querySelectorAll('[aria-label="GitHub integration actions"]')).toHaveLength(1) + expect(mocks.accountMenu).toHaveBeenLastCalledWith( + expect.objectContaining({ accounts: [githubAccount] }) ) - expect(document.body.textContent).not.toContain('Your accounts') - expect(document.body.textContent).not.toContain('Manage sources') - expect(buttons('slack-return')).toHaveLength(1) + expect(buttons('Connect')).toHaveLength(0) + expect(buttons('Reconnect')).toHaveLength(0) + expect(mocks.sources).not.toHaveBeenCalled() + expect(container.textContent).not.toContain('future-repository') }) - it('always requests personal connections even with an old All tab URL', async () => { - vi.useFakeTimers() - await act(async () => root.render()) - mocks.filters.mockReturnValue({ tab: 'all', search: ' drive ', setSearch: vi.fn() }) - await act(async () => root.render()) - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) - await act(async () => vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS)) - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'drive', mine: true }) + it('keeps an owned account visible before any repository source exists', async () => { + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + mocks.integrations.mockReturnValue({ data: [], isPending: false }) + mocks.organizationAccounts.mockReturnValue({ + data: { credentialGroup: githubGroup, viewerAccounts: [githubAccount] }, + isPending: false, + }) + await render('', ) + expect(container.textContent).toContain('My GitHub · Connected') + expect(document.querySelectorAll('[aria-label="GitHub integration actions"]')).toHaveLength(1) + expect(mocks.sources).not.toHaveBeenCalled() + expect(buttons('Connect')).toHaveLength(0) + expect(container.textContent).not.toContain('No integrations are available') }) - it('refreshes organization Accounts after either direct connection flow completes', async () => { - await act(async () => root.render()) - expect(mocks.enrollment.mock.calls.length).toBeGreaterThanOrEqual(2) - for (const [options] of mocks.enrollment.mock.calls) { - expect(options).toMatchObject({ - directOAuth: true, - membershipQueryKeys: expect.arrayContaining([ - organizationAccountsKeys.detail(scope.organizationId), - ]), + it.each(['group', 'option'] as const)( + 'keeps Disconnect but hides Reconnect when the canonical %s is disabled', + async (disabled) => { + const expired = { ...githubAccount, status: 'needs_reauth' } + mocks.organizationAccounts.mockReturnValue({ + data: { + credentialGroup: { + ...githubGroup, + status: disabled === 'group' ? 'disabled' : 'active', + options: [ + { ...githubGroup.options[0], status: disabled === 'option' ? 'disabled' : 'active' }, + ], + }, + viewerAccounts: [expired], + }, + isPending: false, }) + await render() + expect(buttons('Reconnect')).toHaveLength(0) + expect(mocks.accountMenu).toHaveBeenLastCalledWith( + expect.objectContaining({ accounts: [expired] }) + ) + await openMenu('GitHub') + expect(menuItem('Disconnect github-account')).toBeDefined() } - }) + ) - it('offers an approved integration before any source is configured', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) - mocks.overview.mockReturnValue({ - data: { providers: [], hasSearchableDocuments: false }, - isPending: false, - }) + 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) => { + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'gmail', approved: true }], + data: [{ connectorType: 'github', approved: false }], isPending: false, }) - await render() - expect(document.body.textContent).toContain('Connect your account to search this source') - expect(buttons('Connect')).toHaveLength(1) - await act(async () => buttons('Connect')[0].click()) - expect(mocks.connect).toHaveBeenCalledWith( - scope, - expect.objectContaining({ type: 'gmail' }), - undefined - ) - }) - it('allows a second approved content scope after another source is configured', async () => { - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, connectorType: 'confluence', sourceDescription: 'ENG' }], - isPending: false, - }) - mocks.overview.mockReturnValue({ - data: { providers: [{ connectorType: 'confluence' }] }, - isPending: false, + mocks.context.mockReturnValue({ + organization: { id: scope.organizationId }, + searchAccess: { memberScoped: false, sourceMirrored: false }, }) - mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'confluence', approved: true }], + mocks.organizationAccounts.mockReturnValue({ + data: { + credentialGroup: githubGroup, + viewerAccounts: [{ ...githubAccount, status: 'needs_reauth' }], + }, isPending: false, }) - mocks.availability.mockReturnValue({ - integrationAvailability: new Map(), - oauthServiceAvailability: new Map([['confluence', true]]), - isIntegrationAvailabilityReady: true, - }) await render() - expect(buttons('Connect')).toHaveLength(2) - await act(async () => buttons('Connect')[1].click()) - expect(mocks.connect).toHaveBeenCalledWith( - scope, - expect.objectContaining({ type: 'confluence' }), - undefined + expect(buttons('Reconnect')).toHaveLength(1) + await act(async () => buttons('Reconnect')[0].click()) + expect(mocks.reconnectOrganizationAccount).toHaveBeenCalledExactlyOnceWith( + 'github-account', + expect.any(Object) ) + expectConnectionRedirect(mocks.reconnectOrganizationAccount.mock.calls[0][1].onSuccess, url) + expect(mocks.connect).not.toHaveBeenCalled() }) - it('keeps configured sources in alphabetical order with approved providers', async () => { - mocks.sources.mockReturnValue({ data: [memberSource], isPending: false }) - mocks.integrations.mockReturnValue({ - data: [ - { connectorType: 'confluence', approved: true }, - { connectorType: 'jira', approved: true }, - ], + it('does not reconnect an account through a different active option', async () => { + mocks.organizationAccounts.mockReturnValue({ + data: { + credentialGroup: githubGroup, + viewerAccounts: [{ ...githubAccount, optionId: 'other-option', status: 'needs_reauth' }], + }, isPending: false, }) await render() - const text = container.textContent ?? '' - expect(text.indexOf('Confluence')).toBeLessThan(text.indexOf('Gmail')) - expect(text.indexOf('Gmail')).toBeLessThan(text.indexOf('Jira')) + expect(buttons('Reconnect')).toHaveLength(0) + expect(document.querySelector('[aria-label="GitHub integration actions"]')).not.toBeNull() }) - it('withholds connection when an integration is deactivated', async () => { - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, approved: false }], + it.each(['pending', 'error'] as const)( + 'does not fall through to repository setup when the inventory is %s', + async (state) => { + mocks.organizationAccounts.mockReturnValue({ + data: undefined, + isPending: state === 'pending', + isError: state === 'error', + error: state === 'error' ? new Error('Could not load accounts') : null, + isFetching: false, + refetch: mocks.refetchAccounts, + }) + await render() + expect(container.textContent).toContain('GitHub') + expect(mocks.sources).not.toHaveBeenCalled() + expect(buttons('Connect')).toHaveLength(0) + expect(mocks.connectSearchSource).not.toHaveBeenCalled() + if (state === 'error') { + await act(async () => buttons('Retry')[0].click()) + expect(mocks.refetchAccounts).toHaveBeenCalledOnce() + } + } + ) + + it('retains legacy account management only after a successful response omits the inventory', async () => { + mocks.organizationAccounts.mockReturnValue({ + data: { credentialGroup: githubGroup }, isPending: false, + isError: false, }) + rows = rows.map((source) => ({ + ...source, + viewerMembership: 'connected', + viewerAccounts: [githubAccount], + })) await render() + expect(mocks.sources).toHaveBeenCalledWith(scope, { connectorType: 'github', enabled: true }) + expect(document.querySelectorAll('[aria-label="GitHub integration actions"]')).toHaveLength(1) + expect(mocks.accountMenu).toHaveBeenLastCalledWith( + expect.objectContaining({ accounts: [githubAccount] }) + ) expect(buttons('Connect')).toHaveLength(0) - expect(document.body.textContent).not.toContain('Gmail') }) - it('waits for availability before describing approved sources as needing admin setup', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) +}) + +describe('grouped member integrations', () => { + it('renders one provider row and loads bounded pages per configured provider', async () => { mocks.overview.mockReturnValue({ - data: { providers: [], hasSearchableDocuments: false }, + data: { providers: [{ connectorType: 'gmail' }, { connectorType: 'google_drive' }] }, isPending: false, }) - mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'gmail', approved: true }], - isPending: false, - }) - mocks.availability.mockReturnValue({ isIntegrationAvailabilityReady: false }) + rows = Array.from({ length: 25 }, (_, index) => ({ + ...memberSource, + connectorId: `gmail-${index}`, + })) await render() - expect(document.body.textContent).toContain('Loading sources') - expect(document.body.textContent).not.toContain('An admin needs to finish source setup') + expect(buttons('Connect')).toHaveLength(1) + expect(container.textContent).toContain('Google Drive') + expect(container.textContent).not.toContain('Inbox') + expect(mocks.sources).toHaveBeenCalledWith(scope, { connectorType: 'gmail', enabled: true }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { + connectorType: 'google_drive', + enabled: true, + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + it('keeps the list flat even when an old details URL is opened', async () => { + rows = [ + memberSource, + { ...memberSource, connectorId: 'source-b', sourceDescription: 'Archive' }, + ] + await render('?integration=gmail') + expect(document.querySelector('[role="region"]')).toBeNull() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.textContent).not.toContain('Inbox') + expect(container.textContent).not.toContain('Archive') + expect(document.querySelector('[aria-label="Gmail integration actions"]')).toBeNull() + expect(buttons('Connect')).toHaveLength(1) + }) + it('connects one configured target even with many same-provider content scopes', async () => { + rows = [ + memberSource, + { ...memberSource, connectorId: 'source-b', sourceDescription: 'Archive' }, + ] + await render('?integration=gmail') + expect(buttons('Connect')).toHaveLength(1) + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'source-a') + }) + it('deduplicates the same account across scopes', async () => { + rows = [memberSource, { ...memberSource, connectorId: 'source-b' }].map((source) => ({ + ...source, + viewerMembership: 'connected', + viewerAccounts: [account], + })) + await render('?integration=gmail') + expect(mocks.accountMenu).toHaveBeenCalledWith(expect.objectContaining({ accounts: [account] })) expect(buttons('Connect')).toHaveLength(0) + expect(buttons('Reconnect')).toHaveLength(0) + expect(container.textContent).toContain('Connected') }) - it('retries availability failures instead of asking an admin to finish setup', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) - mocks.overview.mockReturnValue({ - data: { providers: [], hasSearchableDocuments: false }, - isPending: false, - }) - mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'gmail', approved: true }], - isPending: false, - }) - mocks.availability.mockReturnValue({ - isIntegrationAvailabilityReady: false, - integrationAvailabilityError: new Error('Connection availability failed'), - refetchIntegrationAvailability: mocks.refetchAvailability, - isIntegrationAvailabilityFetching: false, - }) + it('distinguishes same-name accounts by content and renewal state only when needed', async () => { + rows = [ + { ...memberSource, sourceDescription: 'Engineering', viewerAccounts: [account] }, + { + ...memberSource, + connectorId: 'source-b', + sourceDescription: 'Handbook', + viewerAccounts: [{ ...account, credentialId: 'expired', status: 'needs_reauth' }], + }, + ] + await render() + expect(mocks.accountMenu.mock.calls.at(-1)?.[0].accountLabels).toEqual( + new Map([ + ['account', 'Engineering · My work account'], + ['expired', 'Reconnect required · Handbook · My work account'], + ]) + ) + }) + + it('gives otherwise identical accounts distinct connection labels', async () => { + rows = [ + { + ...memberSource, + viewerAccounts: [account, { ...account, credentialId: 'second' }], + }, + ] await render() - expect(document.body.textContent).toContain('Connection availability failed') - expect(document.body.textContent).not.toContain('An admin needs to finish source setup') + expect(mocks.accountMenu.mock.calls.at(-1)?.[0].accountLabels).toEqual( + new Map([ + ['account', 'Connection 1 · Inbox · My work account'], + ['second', 'Connection 2 · Inbox · My work account'], + ]) + ) + }) + it.each(['personal', 'central'] as const)( + 'keeps existing %s content connected when another source needs authorization', + async (kind) => { + const connected: SearchSourceSummary = + kind === 'personal' + ? { ...memberSource, viewerMembership: 'connected', viewerAccounts: [account] } + : { ...centralSource, connectorType: 'gmail' } + rows = [connected, { ...memberSource, connectorId: 'additional-source' }] + await render() + expect(container.textContent).toContain('Additional connection required') + expect(container.textContent).not.toContain('Not connected') + expect(buttons('Connect')).toHaveLength(1) + expect(buttons('Reconnect')).toHaveLength(0) + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'additional-source') + } + ) + it('does not ask for authorization again when connected content fails to sync', async () => { + rows = [memberSource, { ...memberSource, connectorId: 'source-b' }].map((source) => ({ + ...source, + viewerMembership: 'connected', + viewerAccounts: [account], + hasSyncError: true, + })) + await render() + expect(container.textContent).toContain('Sync needs attention') expect(buttons('Connect')).toHaveLength(0) - await act(async () => buttons('Try again')[0].click()) - expect(mocks.refetchAvailability).toHaveBeenCalledOnce() + expect(buttons('Reconnect')).toHaveLength(0) + }) + it('reconnects the expired target before any connected account', async () => { + rows = [ + { ...memberSource, viewerMembership: 'connected', viewerAccounts: [account] }, + { + ...memberSource, + connectorId: 'expired-source', + viewerMembership: 'needs_reauth', + viewerAccounts: [{ ...account, credentialId: 'expired-account', status: 'needs_reauth' }], + }, + { ...memberSource, connectorId: 'unconnected-source' }, + ] + await render('?integration=gmail') + await act(async () => buttons('Reconnect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'expired-source') }) - it('hides Slack until its organization setup is ready', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) + it('explains central connections without asking for a personal account', async () => { mocks.overview.mockReturnValue({ - data: { providers: [], hasSearchableDocuments: false }, + data: { providers: [{ connectorType: 'google_drive', isSyncing: false }] }, isPending: false, }) mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'slack', approved: true }], + data: [{ connectorType: 'google_drive', approved: true }], isPending: false, }) - await render() + rows = [centralSource] + await render('?integration=google_drive') + expect(container.textContent).toContain('Google Drive') expect(buttons('Connect')).toHaveLength(0) - expect(document.body.textContent).not.toContain('Slack') - expect(document.body.textContent).toContain('No integrations are available to connect.') }) - - it('hides an approved provider when its OAuth configuration is missing', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) - mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) - mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'gmail', approved: true }], - isPending: false, - }) - mocks.availability.mockReturnValue({ - integrationAvailability: new Map(), - oauthServiceAvailability: new Map([['google-email', false]]), - isIntegrationAvailabilityReady: true, - }) + it.each([{ enabled: false }, { approved: false }, { availability: 'unavailable' as const }])( + 'retains own-account removal when a source cannot connect: %o', + async (override) => { + rows = [ + { + ...memberSource, + viewerMembership: 'needs_reauth', + viewerAccounts: [account], + ...override, + }, + ] + await render('?integration=gmail') + expect(mocks.accountMenu).toHaveBeenCalledWith( + expect.objectContaining({ accounts: [account] }) + ) + expect(buttons('Reconnect')).toHaveLength(0) + } + ) + it.each(['members', 'admin'] as const)( + 'does not claim connected when multi-scope %s access is disabled', + async (accessMode) => { + mocks.context.mockReturnValue({ + organization: { id: scope.organizationId }, + searchAccess: { memberScoped: false, sourceMirrored: false }, + }) + rows = [memberSource, { ...memberSource, connectorId: 'source-b' }].map((source) => ({ + ...source, + accessMode, + isSyncing: true, + viewerMembership: 'connected', + viewerAccounts: [account], + })) + await render() + expect(container.textContent).toContain('Some connections need attention') + expect(container.textContent).not.toContain('Indexing') + expect(buttons('Connect')).toHaveLength(0) + expect(mocks.accountMenu).toHaveBeenCalledWith( + expect.objectContaining({ accounts: [account] }) + ) + } + ) + it('keeps email verification as account recovery and returns to the integration list', async () => { + rows = [{ ...memberSource, viewerEmailVerified: false, viewerMembership: 'unverified_email' }] await render() - expect(document.body.textContent).not.toContain('Gmail') + expect(container.textContent).toContain('Verify your email') expect(buttons('Connect')).toHaveLength(0) - expect(document.body.textContent).toContain('No integrations are available to connect.') + const recovery = container.querySelector('a[href^="/verify"]')! + expect(new URL(recovery.href).searchParams.get('redirectAfter')).toBe( + '/o/organization-a/integrations' + ) }) - - it('offers personal Slack connection once source setup is complete', async () => { - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, connectorType: 'slack', accessMode: 'admin' }], - isPending: false, - }) - await render() - expect(document.body.textContent).toContain('Slack') - expect(buttons('Connect')).toHaveLength(1) - expect(document.body.textContent).not.toContain('Finish Slack setup') - await act(async () => buttons('Connect')[0].click()) - expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') + it.each([{ enabled: false }, { approved: false }, { availability: 'unavailable' as const }])( + 'does not offer verification for blocked content: %o', + async (override) => { + rows = [memberSource, { ...memberSource, connectorId: 'source-b' }].map((source) => ({ + ...source, + ...override, + viewerEmailVerified: false, + viewerMembership: 'unverified_email', + })) + await render() + expect(container.querySelector('a[href^="/verify"]')).toBeNull() + expect(container.textContent).toContain('Some connections need attention') + expect(buttons('Connect')).toHaveLength(0) + } + ) + it('preserves explicit pagination instead of pretending loaded scope counts are complete', async () => { + queryOverrides = { hasNextPage: true } + rows = [] + await render('?integration=gmail') + expect(container.textContent).toContain('More connections to check') + await act(async () => buttons('Check connections')[0].click()) + expect(mocks.nextPage).toHaveBeenCalledOnce() + expect(buttons('Connect')).toHaveLength(0) }) - it('also hides unfinished Slack setup from admins on this personal surface', async () => { - mocks.context.mockReturnValue({ - organization: { id: scope.organizationId }, - viewer: { isAdmin: true }, - searchAccess: { memberScoped: true, sourceMirrored: true }, - }) - mocks.sources.mockReturnValue({ data: [], isPending: false }) - mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) - mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'slack', approved: true }], - isPending: false, - }) + it.each(['needs_reauth', 'not_enrolled'] as const)( + 'exposes an older %s source without marking a partial inventory connected', + async (membership) => { + rows = Array.from({ length: 25 }, (_, index) => ({ + ...memberSource, + connectorId: `source-${index}`, + viewerMembership: 'connected', + viewerAccounts: [account], + })) + queryOverrides = { hasNextPage: true } + await render() + expect(container.textContent).toContain('More connections to check') + expect(container.textContent).not.toContain('Connected') + expect(buttons('Connect')).toHaveLength(0) + await act(async () => buttons('Check connections')[0].click()) + expect(mocks.nextPage).toHaveBeenCalledOnce() + queryOverrides = { hasNextPage: true, isFetchingNextPage: true, isFetching: true } + await render() + expect(buttons('Checking…')[0]).toBeDisabled() + rows = [ + ...rows, + { ...memberSource, connectorId: 'older-source', viewerMembership: membership }, + ] + queryOverrides = { hasNextPage: false } + await render() + expect(buttons('Check connections')).toHaveLength(0) + const action = membership === 'needs_reauth' ? 'Reconnect' : 'Connect' + await act(async () => buttons(action)[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'older-source') + } + ) + it('retries a failed connection read directly from its flat row', async () => { + queryOverrides = { isError: true, error: new Error('Could not load') } await render() - expect( - document.querySelector('a[href="/o/organization-a/settings/integrations/providers/slack"]') - ).toBeNull() - expect(document.body.textContent).not.toContain('An admin needs to finish source setup') expect(buttons('Connect')).toHaveLength(0) + expect(document.querySelector('[role="region"]')).toBeNull() + await act(async () => buttons('Retry')[0].click()) + expect(mocks.refetch).toHaveBeenCalledOnce() }) - - it('keeps source administration off the personal page for admins', async () => { - mocks.context.mockReturnValue({ - organization: { id: scope.organizationId }, - viewer: { isAdmin: true }, - searchAccess: { memberScoped: true, sourceMirrored: true }, - }) - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, viewerMembership: 'connected' }], - isPending: false, - }) - await act(async () => root.render()) - expect(document.body.textContent).not.toContain('Manage sources') - expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).toBeNull() - expect(document.querySelector('[aria-label$="source actions"]')).toBeNull() - expect(buttons('Connect')).toHaveLength(0) + it('keeps later connection pages retryable without an expanded section', async () => { + queryOverrides = { hasNextPage: true, isError: true, isFetchNextPageError: true } + rows = [] + await render() + expect(container.textContent).toContain('Could not check remaining connections') + await act(async () => buttons('Retry')[0].click()) + expect(mocks.nextPage).toHaveBeenCalledOnce() + expect(document.querySelector('[role="region"]')).toBeNull() }) - - it('shows ready integrations inline and connects without an intermediate dialog', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) - mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'gmail', approved: true }], - isPending: false, - }) + it('offers direct Connect only for a new eligible provider, with no duplicate scope row', async () => { mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) - await act(async () => root.render()) - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) - expect(document.body.textContent).toContain('Gmail') - expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(buttons('Connect account')).toHaveLength(0) + await render() + expect(buttons('Connect')).toHaveLength(1) await act(async () => buttons('Connect')[0].click()) - expect(mocks.connect).toHaveBeenCalledWith( + expect(mocks.connectSearchSource).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'gmail' }), undefined ) + expect(document.querySelector('[role="dialog"]')).toBeNull() }) - - it('filters available providers using the same search as personal connections', async () => { - mocks.sources.mockReturnValue({ data: [], isPending: false }) + it.each([ + { data: undefined, isPending: true, isError: false }, + { data: undefined, isPending: false, isError: true, error: new Error('Slack unavailable') }, + ])( + 'keeps other integrations usable when Slack inventory is unavailable: %o', + async (inventory) => { + mocks.slackInventory.mockReturnValue(inventory) + await render() + expect(buttons('Connect')).toHaveLength(1) + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'source-a') + expect(container.textContent).toContain('Gmail') + } + ) + it.each([false, true])( + 'preserves shared Slack onboarding without a duplicate row (configured: %s)', + async (configured) => { + mocks.overview.mockReturnValue({ + data: { providers: configured ? [{ connectorType: 'slack' }] : [] }, + isPending: false, + }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'slack', approved: true }], + isPending: false, + }) + mocks.availability.mockReturnValue({ + integrationAvailability: new Map([['slack_v2', { state: 'ready', oauthAvailable: true }]]), + oauthServiceAvailability: new Map([['slack', true]]), + isIntegrationAvailabilityReady: true, + }) + mocks.slackInventory.mockReturnValue({ + data: { available: [{ target: { connectorType: 'slack' } }] }, + isPending: false, + isError: false, + }) + rows = configured ? [{ ...memberSource, connectorType: 'slack' }] : [] + await render() + expect(buttons('Connect')).toHaveLength(1) + expect(mocks.slackInventory).toHaveBeenCalledWith({ + organizationId: scope.organizationId, + connectorType: 'slack', + }) + await act(async () => buttons('Connect')[0].click()) + if (configured) { + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'source-a') + expect(mocks.connectSearchSource).not.toHaveBeenCalled() + } else { + expect(mocks.connectSearchSource).toHaveBeenCalledExactlyOnceWith( + scope, + expect.objectContaining({ type: 'slack' }), + undefined + ) + expect(mocks.connect).not.toHaveBeenCalled() + } + expect(document.querySelector('[role="dialog"]')).toBeNull() + } + ) + it('keeps Slack setup errors relevant to the selected integration filter', async () => { mocks.integrations.mockReturnValue({ data: [ { connectorType: 'gmail', approved: true }, - { connectorType: 'jira', approved: true }, + { connectorType: 'slack', approved: true }, ], isPending: false, }) - mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) - await act(async () => root.render()) - expect(document.body.textContent).toContain('Gmail') - expect(document.body.textContent).not.toContain('Jira') - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'gmail' }) - }) - - it('lets the viewer reconnect their own expired account from the main page', async () => { - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, viewerMembership: 'needs_reauth' }], + mocks.slackInventory.mockReturnValue({ isPending: false, + isError: true, + error: new Error('Could not load Slack setup'), }) - await act(async () => root.render()) - expect(document.body.textContent).toContain('Your account needs to be reconnected') - await act(async () => buttons('Reconnect')[0].click()) - expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') + await render('', ) + expect(container.textContent).not.toContain('Could not load Slack setup') + expect(buttons('Connect')).toHaveLength(1) + await render('', ) + expect(container.textContent).toContain('Could not load Slack setup') + expect(container.textContent).not.toContain('No integrations are available to connect') + expect(container.textContent).not.toContain('No matching integrations') }) - - it('does not offer connection to an unavailable source or setup to a member with no sources', async () => { - mocks.context.mockReturnValue({ - organization: { id: scope.organizationId }, - viewer: { isAdmin: false }, - searchAccess: { memberScoped: false, sourceMirrored: false }, + it.each([ + { data: { available: [] }, isPending: false, isError: false }, + { + data: { available: [{ target: { connectorType: 'slack', connectorId: 'existing' } }] }, + isPending: false, + isError: false, + }, + { data: undefined, isPending: true, isError: false }, + { data: undefined, isPending: false, isError: true, error: new Error('Could not load Slack') }, + ])('withholds new Slack setup without a ready shared-app target: %o', async (inventory) => { + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'slack', approved: true }], + isPending: false, }) + mocks.availability.mockReturnValue({ + integrationAvailability: new Map([['slack_v2', { state: 'ready', oauthAvailable: true }]]), + oauthServiceAvailability: new Map([['slack', true]]), + isIntegrationAvailabilityReady: true, + }) + mocks.slackInventory.mockReturnValue(inventory) await render() expect(buttons('Connect')).toHaveLength(0) - expect(document.body.textContent).not.toContain('Gmail') - mocks.sources.mockReturnValue({ data: [], isPending: false }) + expect(mocks.connectSearchSource).not.toHaveBeenCalled() + }) + it('withholds new setup on failed/incomplete provider data', async () => { mocks.overview.mockReturnValue({ - data: { providers: [], hasSearchableDocuments: false }, + data: { providers: [{ connectorType: 'confluence', isSyncing: false }] }, isPending: false, }) - await render() - expect(document.body.textContent).toContain('No integrations are available to connect.') - expect(buttons('Add source')).toHaveLength(0) - }) - it('keeps sparse source pages navigable without claiming missing sources or duplicating configured providers', async () => { - const fetchNextPage = vi.fn() - mocks.sources.mockReturnValue({ data: [], isPending: false, hasNextPage: true, fetchNextPage }) mocks.integrations.mockReturnValue({ - data: [{ connectorType: 'gmail', approved: true }], + data: [{ connectorType: 'confluence', approved: true }], isPending: false, }) - await render() - expect(buttons('Load more')).toHaveLength(1) - expect(buttons('Connect')).toHaveLength(0) - expect(document.body.textContent).not.toContain('hasn’t added any sources') - await act(async () => buttons('Load more')[0].click()) - expect(fetchNextPage).toHaveBeenCalledOnce() + rows = [{ ...memberSource, connectorType: 'confluence' }] + queryOverrides = { isError: true, error: new Error('Could not load'), hasNextPage: false } + await render('?integration=confluence') + expect( + mocks.accountMenu.mock.calls.at(-1)?.[0].actions.map((action: RowAction) => action.label) + ).toEqual([]) + queryOverrides = { hasNextPage: true } + await render('?integration=confluence') + expect( + mocks.accountMenu.mock.calls.at(-1)?.[0].actions.map((action: RowAction) => action.label) + ).toEqual([]) }) - - it('retains loaded rows on a next-page failure and retries only that page', async () => { - const fetchNextPage = vi.fn() - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, sourceDescription: 'Engineering' }], + it('keeps the integration menu open during background indexing refreshes', async () => { + mocks.overview.mockReturnValue({ + data: { providers: [{ connectorType: 'confluence', isSyncing: true }] }, isPending: false, - isError: true, - isFetchNextPageError: true, - hasNextPage: true, - error: new Error('Could not load more sources'), - fetchNextPage, }) - await render() - expect(document.body.textContent).toContain('Engineering') - expect(document.body.textContent).toContain('Could not load more sources') - await act(async () => buttons('Try again')[0].click()) - expect(fetchNextPage).toHaveBeenCalledOnce() + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'confluence', approved: true }], + isPending: false, + }) + rows = [{ ...memberSource, connectorType: 'confluence' }] + await render('?integration=confluence') + const trigger = document.querySelector( + '[aria-label="Confluence integration actions"]' + )! + await act(async () => + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(document.querySelector('[role="menu"]')).not.toBeNull() + queryOverrides = { isFetching: true } + await render('?integration=confluence') + expect(document.querySelector('[aria-label="Confluence integration actions"]')).toBe(trigger) + expect(document.querySelector('[role="menu"]')).not.toBeNull() + }) + it('retains typed connection requests and Slack onboarding on the main page', async () => { + const connectionRequest = { + userId: 'person', + target: { + type: 'link' as const, + provider: 'google-email', + connectorType: 'gmail', + connectorId: 'source-a', + }, + } + await render( + '', + + ) + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ ...connectionRequest, organizationId: scope.organizationId }) + ) + expect(container.textContent).toContain('Return to Slack') + }) + it('keeps scoped enrollment invalidations and toast error handling', async () => { + await render('?integration=gmail') + const options = mocks.enrollment.mock.calls.at(-1)?.[0] as { + membershipQueryKeys: unknown[] + onConnectionError: (message: string) => void + } + expect(options.membershipQueryKeys).toContainEqual( + organizationAccountsKeys.detail(scope.organizationId) + ) + options.onConnectionError('Choose the matching account') + expect(toast.error).toHaveBeenCalledExactlyOnceWith('Choose the matching account') + expect(document.body.textContent).not.toContain('Choose the matching account') }) }) diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index da715657d45..821947123f3 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -1,27 +1,14 @@ 'use client' -import { useMemo } from 'react' -import { toast } from '@sim/emcn' -import type { ResourceScope } from '@/lib/core/resource-scope' import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' -import { connectorDisplayName } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' -import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' -import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' +import { MemberIntegrationsList } from '@/app/o/[organizationId]/integrations/member-integrations-list' import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' -import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' -import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' -import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { useSearchSources } from '@/hooks/queries/kb/connectors' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' -import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { useDebounce } from '@/hooks/use-debounce' -import { useMemberEnrollment } from '@/hooks/use-member-enrollment' import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return' interface OrganizationIntegrationsProps { @@ -29,40 +16,15 @@ interface OrganizationIntegrationsProps { slackOnboarding?: { token: string; userId: string } } -/** The viewer's Search connections and ready integrations they can connect personally. */ export function OrganizationIntegrations({ slackOnboarding, connectionRequest, }: OrganizationIntegrationsProps = {}) { useOAuthReturnRouter() useDesktopOAuthConnectListener() - const { organization, searchAccess } = useOrganizationContext() - const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } + const { organization } = useOrganizationContext() const { search } = useOrganizationPageFilters() const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const sources = useSearchSources(scope, { search: sourceSearch, mine: true }) - const membershipQueryKeys = useMemo( - () => [ - searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), - organizationAccountsKeys.detail(organization.id), - ], - [organization.id] - ) - const connectedConnectorIds = useMemo( - () => - new Set( - sources.data - ?.filter((source) => source.viewerMembership === 'connected') - .map((source) => source.connectorId) - ), - [sources.data] - ) - const enrollment = useMemberEnrollment({ - membershipQueryKeys, - connectedConnectorIds, - directOAuth: true, - onConnectionError: toast.error, - }) return ( )} -
- {sources.isError && !sources.isFetchNextPageError ? ( - void sources.refetch()} - variant='inline' - /> - ) : !sources.isPending && (sources.data?.length || sources.hasNextPage) ? ( - <> - {sources.data?.map((source) => ( - - ) : undefined - } - available={ - source.accessMode === 'members' - ? searchAccess.memberScoped - : searchAccess.sourceMirrored && - (!source.connectionRequired || searchAccess.memberScoped) - } - waiting={enrollment.isAwaiting(source.connectorId)} - isPending={enrollment.isPending} - onConnect={() => enrollment.connect(source.knowledgeBaseId, source.connectorId)} - /> - ))} - - - ) : null} -
- +
) } diff --git a/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx b/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx new file mode 100644 index 00000000000..70c9061b950 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx @@ -0,0 +1,223 @@ +'use client' + +import { Chip, ChipLink } from '@sim/emcn' +import { organizationRoutes } from '@/lib/navigation/paths' +import { connectorDisplayName } from '@/lib/sim-search/connectors' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { getSearchSourceStatus } from '@/app/workspace/[workspaceId]/search/components/search-source-row-status' +import type { RowAction } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import type { useSearchSources } from '@/hooks/queries/kb/connectors' +import { + CONNECTABLE_MEMBERSHIPS, + enrollmentActionLabel, + type useMemberEnrollment, +} from '@/hooks/use-member-enrollment' + +interface MemberIntegrationRowProps { + organizationId: string + connectorType: string + configured: boolean + sources: ReturnType + enrollment: ReturnType + memberAccessAvailable: boolean + mirroredAccessAvailable: boolean + onCreate?: () => void + addLabel?: string +} + +/** One flat account row per integration, independent of how many content scopes it indexes. */ +export function MemberIntegrationRow({ + organizationId, + connectorType, + configured, + sources, + enrollment, + memberAccessAvailable, + mirroredAccessAvailable, + onCreate, + addLabel, +}: MemberIntegrationRowProps) { + const name = connectorDisplayName(connectorType) + const meta = CONNECTOR_META_REGISTRY[connectorType] + const rows = sources.data ?? [] + const accounts = [ + ...new Map( + rows + .flatMap((source) => source.viewerAccounts) + .map((account) => [account.credentialId, account]) + ).values(), + ] + const accountLabels = new Map() + for (const account of accounts) { + if (accounts.filter((other) => other.displayName === account.displayName).length < 2) continue + const descriptions = [ + ...new Set( + rows + .filter((source) => + source.viewerAccounts.some((other) => other.credentialId === account.credentialId) + ) + .map((source) => source.sourceDescription) + .filter(Boolean) + ), + ] + const context = [ + account.status === 'needs_reauth' ? 'Reconnect required' : undefined, + descriptions.length > 1 ? `${descriptions[0]} +${descriptions.length - 1}` : descriptions[0], + ].filter(Boolean) + accountLabels.set(account.credentialId, [...context, account.displayName].join(' · ')) + } + for (const label of new Set(accountLabels.values())) { + const matching = accounts.filter((account) => accountLabels.get(account.credentialId) === label) + if (matching.length < 2) continue + matching.forEach((account, index) => + accountLabels.set(account.credentialId, `Connection ${index + 1} · ${label}`) + ) + } + const isUsable = (source: (typeof rows)[number]) => + source.availability === 'available' && + (source.accessMode === 'members' + ? memberAccessAvailable + : mirroredAccessAvailable && (!source.connectionRequired || memberAccessAvailable)) + const eligible = rows.filter( + (source) => + source.connectionRequired && + source.viewerEmailVerified && + source.enabled && + source.approved !== false && + isUsable(source) && + source.viewerMembership !== null && + CONNECTABLE_MEMBERSHIPS.has(source.viewerMembership) + ) + const target = + eligible.find((source) => source.viewerMembership === 'needs_reauth') ?? eligible[0] + const hasLoadError = configured && sources.isError && !sources.isFetchNextPageError + const ready = !configured || (!sources.isPending && !hasLoadError) + const allCentral = + rows.length > 0 && !sources.hasNextPage && rows.every((source) => !source.connectionRequired) + const needsEmailVerification = rows.some( + (source) => + source.enabled && source.approved !== false && isUsable(source) && !source.viewerEmailVerified + ) + const waiting = target + ? enrollment.isAwaiting(target.connectorId) + : enrollment.isAwaitingSource(connectorType) + const status = (source: (typeof rows)[number]) => + getSearchSourceStatus({ + source, + scopeKind: 'organization', + supported: meta?.search === true, + usable: isUsable(source), + connectable: eligible.includes(source), + waiting: enrollment.isAwaiting(source.connectorId), + }) + function description() { + if (!configured) return waiting ? 'Finish connecting in the other tab' : 'Not connected' + if (hasLoadError) return 'Could not load connection' + if (sources.isPending) return 'Loading connection…' + if (target) { + if (waiting) return 'Finish connecting in the other tab' + if (target.viewerMembership === 'needs_reauth') return 'Reconnect your account' + const hasConnectedContent = rows.some( + (source) => + source.enabled && + source.approved !== false && + isUsable(source) && + (!source.connectionRequired || source.viewerMembership === 'connected') + ) + return hasConnectedContent ? 'Additional connection required' : 'Not connected' + } + if (rows.length === 1 && !sources.hasNextPage) return status(rows[0]) + if (needsEmailVerification) return 'Verify your email' + if ( + rows.some( + (source) => + !source.enabled || + source.approved === false || + !isUsable(source) || + source.viewerMembership === 'revoked' || + (source.connectionRequired && source.viewerMembership === null) + ) + ) + return 'Some connections need attention' + if (rows.some((source) => source.hasSyncError || source.viewerFailedDocumentCount > 0)) + return 'Sync needs attention' + if (rows.some((source) => source.isSyncing)) return 'Indexing' + if (sources.hasNextPage) + return sources.isFetchNextPageError + ? 'Could not check remaining connections' + : 'More connections to check' + if (allCentral) return 'Connected by your organization' + return accounts.length ? 'Connected' : 'No connected content' + } + const actions: RowAction[] = [] + if (configured && ready && !sources.hasNextPage && !allCentral && addLabel && onCreate) + actions.push({ label: addLabel, onSelect: onCreate, disabled: enrollment.isPending }) + const canCheckConnections = + configured && ready && sources.hasNextPage && !target && !needsEmailVerification + const canConnect = ready && (target || (!configured && onCreate)) + + return ( + : undefined} + title={name} + description={description()} + trailing={ +
+ + {ready && needsEmailVerification && ( + + Verify email + + )} + {hasLoadError && ( + void sources.refetch()}> + {sources.isFetching ? 'Retrying…' : 'Retry'} + + )} + {canCheckConnections && ( + void sources.fetchNextPage({ cancelRefetch: false })} + > + {sources.isFetchingNextPage + ? 'Checking…' + : sources.isFetchNextPageError + ? 'Retry' + : 'Check connections'} + + )} + {canConnect && ( + + target + ? enrollment.connect(target.knowledgeBaseId, target.connectorId) + : onCreate?.() + } + > + {target?.viewerMembership + ? enrollmentActionLabel(target.viewerMembership, waiting) + : waiting + ? 'Open again' + : 'Connect'} + + )} +
+ } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/member-integrations-list.tsx b/apps/sim/app/o/[organizationId]/integrations/member-integrations-list.tsx new file mode 100644 index 00000000000..3770b53d4cc --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/member-integrations-list.tsx @@ -0,0 +1,268 @@ +'use client' + +import { useMemo } from 'react' +import { toast } from '@sim/emcn' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels' +import { + getConnectorAccessAvailability, + SEARCH_CONNECTORS, + SEARCH_SOURCE_TYPES, + type SearchConnector, +} from '@/lib/sim-search/connectors' +import { GitHubMemberIntegration } from '@/app/o/[organizationId]/integrations/github-member-integration' +import { MemberIntegrationRow } from '@/app/o/[organizationId]/integrations/member-integration-row' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' +import { + organizationAccountsKeys, + useOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' +import { usePersonalSearchIntegrations } from '@/hooks/queries/personal-search-integrations' +import { useSearchIntegrations } from '@/hooks/queries/search-integrations' +import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { useMemberEnrollment } from '@/hooks/use-member-enrollment' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +interface MemberIntegrationsListProps { + search?: string + showEmpty?: boolean +} + +/** Provider existence comes from the complete overview; account status uses bounded source pages. */ +export function MemberIntegrationsList({ + search = '', + showEmpty = true, +}: MemberIntegrationsListProps = {}) { + const { organization, searchAccess } = useOrganizationContext() + const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } + const overview = useSearchSourceOverview(scope) + const integrations = useSearchIntegrations(organization.id) + const organizationAccounts = useOrganizationAccounts(organization.id) + const githubAccounts = + organizationAccounts.data?.viewerAccounts?.filter( + (account) => account.providerId === 'github-repositories' + ) ?? [] + const usesGitHubInventory = + organizationAccounts.isPending || + organizationAccounts.isError || + organizationAccounts.data?.viewerAccounts !== undefined + const slackInventory = usePersonalSearchIntegrations({ + organizationId: organization.id, + connectorType: 'slack', + }) + const canConnectSharedSlack = + !slackInventory.isError && + slackInventory.data?.available.some( + (entry) => entry.target.connectorType === 'slack' && !entry.target.connectorId + ) === true + const availability = usePermissionConfig() + const configured = new Map( + overview.data?.providers.map((provider) => [provider.connectorType, provider]) + ) + const approved = new Set( + integrations.data + ?.filter((integration) => integration.approved) + .map((integration) => integration.connectorType) + ) + const providers = SEARCH_SOURCE_TYPES.flatMap(([type, meta]) => { + const connector = SEARCH_CONNECTORS.find((entry) => entry.type === type) + const canCreate = Boolean( + connector && + (type !== 'slack' || canConnectSharedSlack) && + approved.has(type) && + getConnectorAccessAvailability(meta, availability.integrationAvailability, { + memberAccessAvailable: searchAccess.memberScoped, + mirroredAccessAvailable: searchAccess.sourceMirrored, + oauthServiceAvailability: availability.oauthServiceAvailability, + isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + }).members + ) + const hasGitHubAccount = + type === 'github' && + (githubAccounts.length > 0 || organizationAccounts.isPending || organizationAccounts.isError) + return configured.has(type) || canCreate || hasGitHubAccount + ? [{ type, meta, connector, canCreate, configured: configured.has(type) }] + : [] + }) + const failedQuery = overview.isError ? overview : integrations.isError ? integrations : null + const query = search.trim().toLowerCase() + const showSlackSetupError = + slackInventory.isError && approved.has('slack') && 'slack'.includes(query) + const visible = providers.filter( + (provider) => + provider.meta.name.toLowerCase().includes(query) || + (provider.type === 'github' && + githubAccounts.some((account) => account.displayName.toLowerCase().includes(query))) + ) + const githubProvider = visible.find((provider) => provider.type === 'github') + const githubRow = + githubProvider && usesGitHubInventory ? ( + + ) : null + + return ( + <> +
+ {failedQuery ? ( + <> + void failedQuery.refetch()} + variant='inline' + /> + {githubRow} + + ) : overview.isPending || integrations.isPending ? ( + <> + Loading integrations… + {githubRow} + + ) : ( + <> + {showSlackSetupError && ( + void slackInventory.refetch()} + variant='inline' + /> + )} + {availability.integrationAvailabilityError && ( + void availability.refetchIntegrationAvailability()} + variant='inline' + /> + )} + {providers.map((provider) => ( + + ))} + {showEmpty && + visible.length === 0 && + !availability.integrationAvailabilityError && + !showSlackSetupError && ( + + {!availability.isIntegrationAvailabilityReady || + (approved.has('slack') && 'slack'.includes(query) && slackInventory.isPending) + ? 'Loading integrations…' + : search + ? 'No matching integrations.' + : 'No integrations are available to connect.'} + + )} + + )} +
+ + ) +} + +interface MemberIntegrationProps { + scope: ResourceScope & { kind: 'organization' } + connectorType: string + connector?: SearchConnector + configured: boolean + canCreate: boolean + memberAccessAvailable: boolean + mirroredAccessAvailable: boolean +} + +/** Each provider loads one bounded page; additional content is loaded explicitly. */ +function MemberIntegration({ + scope, + connectorType, + connector, + configured, + canCreate, + memberAccessAvailable, + mirroredAccessAvailable, +}: MemberIntegrationProps) { + const sources = useSearchSources(scope, { connectorType, enabled: configured }) + const membershipQueryKeys = useMemo( + () => [searchSourceKeys.list(scope), organizationAccountsKeys.detail(scope.organizationId)], + [scope.organizationId] + ) + const connectedConnectorIds = useMemo( + () => + new Set( + sources.data + ?.filter((source) => source.viewerMembership === 'connected') + .map((source) => source.connectorId) + ), + [sources.data] + ) + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + onConnectionError: toast.error, + }) + return ( + <> + enrollment.connectSearchSource(scope, connector, undefined) + : undefined + } + addLabel={ + connector?.setupFields.length + ? getSearchConnectionLabels(connectorType, 'members').add + : undefined + } + /> + {enrollment.setupConnector && ( + + enrollment.connectSource(scope, enrollment.setupConnector!.type, config) + } + /> + )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index 24ef1c21007..41db4871567 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -6,14 +6,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge' import type { ResourceScope } from '@/lib/core/resource-scope' import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import type { useSpeechToText } from '@/hooks/use-speech-to-text' const mocks = vi.hoisted(() => ({ search: vi.fn(), urlUpdate: vi.fn(), push: vi.fn(), + speech: vi.fn(), + toggleListening: vi.fn(), })) -vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }) })) +vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech })) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + usePathname: () => '/o/organization-a/search', +})) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: () => ({ organization: { id: 'organization-a', name: 'Acme' }, @@ -54,6 +61,21 @@ let container: HTMLDivElement beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + ) + mocks.speech.mockReturnValue({ + isSupported: true, + isListening: false, + audioLevelsRef: { current: new Float32Array(5) }, + toggleListening: mocks.toggleListening, + resetTranscript: vi.fn(), + }) mocks.search.mockImplementation((_scope: ResourceScope, query: string) => { const result: WorkspaceKnowledgeSearchResult = { documentId: `document-${query}`, @@ -113,6 +135,30 @@ function expectVisibleQuery(query: string) { } describe('organization Search query navigation', () => { + it.each(['', '?q=Orion'])( + 'dictates into the draft without searching until submit (%s)', + async (params) => { + await render(params) + await editDraft('Find') + const searchCalls = mocks.search.mock.calls.length + const mic = container.querySelector('button[aria-label="Voice input"]')! + expect(mic.nextElementSibling?.getAttribute('aria-label')).toBe('Search') + await act(async () => mic.click()) + expect(mocks.toggleListening).toHaveBeenCalledOnce() + const speech = mocks.speech.mock.calls.at(-1)![0] + expect(speech.organizationId).toBe('organization-a') + await act(async () => speech.onTranscript('release')) + await act(async () => speech.onTranscript('release notes')) + expect(searchInput().value).toBe('Find release notes') + expect(mocks.search).toHaveBeenCalledTimes(searchCalls) + expect(mocks.urlUpdate).not.toHaveBeenCalled() + await act(async () => { + container.querySelector('button[aria-label="Search"]')!.click() + }) + expectVisibleQuery('Find release notes') + } + ) + it('replaces the field draft and results when the committed URL query changes without remounting the page', async () => { await render('?q=Orion') expectVisibleQuery('Orion') diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 581fcee0c2e..7ab8ac1173d 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -17,10 +17,13 @@ import { organizationSearchUrlKeys, } from '@/app/o/[organizationId]/search/search-params' import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results' +import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button' +import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help' import { SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, SIDEBAR_DIVIDER_PAD_BELOW_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useVoiceInput } from '@/hooks/use-voice-input' const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0 transition-colors' const SUBMIT_BUTTON_ACTIVE = @@ -49,7 +52,13 @@ function SearchField({ docked = false, }: SearchFieldProps) { const inputRef = useRef(null) + const { organization } = useOrganizationContext() const [value, setValue] = useState(initialValue) + const voice = useVoiceInput({ + organizationId: organization.id, + getValue: () => value, + onChange: setValue, + }) const canSubmit = value.trim().length > 0 useEffect(() => { @@ -79,21 +88,34 @@ function SearchField({ aria-label='Search your sources' autoComplete='off' spellCheck={false} - className='h-full w-full bg-transparent font-body text-[14px] text-[var(--text-primary)] tracking-[-0.015em] outline-hidden placeholder:text-[var(--text-muted)] [&::-webkit-search-cancel-button]:hidden' + className='h-full min-w-0 flex-1 bg-transparent font-body text-[14px] text-[var(--text-primary)] tracking-[-0.015em] outline-hidden placeholder:text-[var(--text-muted)] [&::-webkit-search-cancel-button]:hidden' /> - + + + ) } diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx new file mode 100644 index 00000000000..0aba56dc5a0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx @@ -0,0 +1,108 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { + ChipInput, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { Search } from '@sim/emcn/icons' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import type { ConnectorMeta } from '@/connectors/types' + +interface AddOrganizationSourceModalProps { + sources: { + type: string + meta: ConnectorMeta + access: { admin: boolean; members: boolean } + }[] + pending: boolean + ready: boolean + feedback: ReactNode + onClose: () => void + onSelect: (type: string, accessMode: 'admin' | 'members') => void +} + +export function AddOrganizationSourceModal({ + sources, + pending, + ready, + feedback, + onClose, + onSelect, +}: AddOrganizationSourceModalProps) { + const [search, setSearch] = useState('') + const query = search.trim().toLowerCase() + const visible = sources.filter(({ meta }) => meta.name.toLowerCase().includes(query)) + + return ( + !open && onClose()} + srTitle='Add source' + > + Add source + + + setSearch(event.target.value)} + disabled={pending} + /> + + + {feedback} +
+ {visible.map(({ type, meta, access }) => { + const available = access.admin || access.members + return ( + } + title={meta.name} + description={ + !ready + ? 'Checking availability…' + : !available + ? 'Unavailable in this deployment' + : access.admin + ? meta.auth.mode === 'apiKey' + ? 'Connect an API token' + : meta.auth.mode === 'oauth' && + meta.auth.adminCredentialType === 'service_account' + ? 'Connect a service account' + : 'Connect an admin account' + : type === 'slack' + ? 'Set up your Slack app' + : 'Connect member accounts' + } + disabled={pending || !ready || !available} + onClick={() => onSelect(type, access.admin ? 'admin' : 'members')} + clickLabel={`Set up ${meta.name}`} + navigable={ready && available} + /> + ) + })} + {visible.length === 0 && ( + No matching sources + )} +
+
+
+ +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx index 952880c34ee..45b6307162e 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx @@ -23,6 +23,14 @@ vi.mock( '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup', () => ({ OrganizationIntegrationsSetup: () =>
Provider setup
}) ) +vi.mock( + '@/app/o/[organizationId]/settings/components/integrations/organization-source-stats', + () => ({ + OrganizationSourceStats: ({ organizationId }: { organizationId: string }) => ( +
Stats for {organizationId}
+ ), + }) +) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, useUpdateOrganizationAccounts: () => ({ @@ -53,6 +61,7 @@ describe('organization integration invitations', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, refetch: mocks.refetch, @@ -111,9 +120,9 @@ describe('organization integration invitations', () => { ) ) const item = document.querySelector('[role="menuitem"]') - expect(item?.textContent).toBe('Refresh connection settings') + expect(item?.textContent).toBe('Update sign-in settings') await act(async () => item?.click()) - expect(document.body.textContent).toContain('Affected accounts will need to reconnect.') + expect(document.body.textContent).toContain('People whose settings changed must reconnect.') } it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { @@ -147,6 +156,7 @@ describe('organization integration invitations', () => { it('refreshes saved provider identities only after choosing the maintenance action and confirming', async () => { mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', @@ -173,7 +183,7 @@ describe('organization integration invitations', () => { mocks.update.mockImplementationOnce((_input, { onSuccess }) => onSuccess()) await render() await openRefresh() - await click('Refresh') + await click('Update') expect(mocks.update).toHaveBeenCalledWith( { organizationId: 'org-a', @@ -198,26 +208,27 @@ describe('organization integration invitations', () => { }, expect.any(Object) ) - expect(toast.success).toHaveBeenCalledWith('Connection settings refreshed') + expect(toast.success).toHaveBeenCalledWith('Sign-in settings updated') expect(document.querySelector('[role="dialog"]')).toBeNull() }) it('keeps failed refreshes open for retry and blocks duplicate submissions', async () => { mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', options: [{ provider: 'gmail' }] } }, error: null, }) await render() await openRefresh() - await click('Refresh') + await click('Update') mocks.updateError = new Error('Update denied') await render() expect(document.body.textContent).toContain('Update denied') expect(document.querySelector('[role="dialog"]')).not.toBeNull() mocks.updatePending = true await render() - expect(findButton('Refresh')).toBeDisabled() + expect(findButton('Update')).toBeDisabled() expect(mocks.update).toHaveBeenCalledOnce() }) @@ -233,11 +244,16 @@ describe('organization integration invitations', () => { expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) }) - it('loads people alongside setup but waits for the pool before allowing invitations', async () => { - mocks.accounts.mockReturnValue({ data: undefined, error: null, isPending: true }) - await render('?tab=people') + it('waits for integration options before loading filtered people or allowing invitations', async () => { + mocks.accounts.mockReturnValue({ + isSuccess: false, + data: undefined, + error: null, + isPending: true, + }) + await render('?tab=people&integration=jira') expect(mocks.accounts).toHaveBeenLastCalledWith('org-a') - expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) expect(container.textContent).toContain('Loading connected accounts') expect(container.textContent).not.toContain('No people invited yet') expect(findButton('Request connections')).toBeDisabled() @@ -245,10 +261,11 @@ describe('organization integration invitations', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, }) - await render('?tab=people') + await render('?tab=people&integration=jira') expect(container.textContent).not.toContain('Loading connected accounts') expect(findButton('Request connections')).not.toBeDisabled() await click('Request connections') @@ -257,14 +274,23 @@ describe('organization integration invitations', () => { }) it('stops the people query when setup resolves without a pool and preserves the setup action', async () => { - mocks.accounts.mockReturnValue({ data: undefined, error: null, isPending: true }) + mocks.accounts.mockReturnValue({ + isSuccess: false, + data: undefined, + error: null, + isPending: true, + }) mocks.people.mockReturnValue({ error: new Error('Organization accounts not configured') }) - await render('?tab=people') - expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) + await render('?tab=people&integration=jira') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) expect(container.textContent).not.toContain('Organization accounts not configured') - mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null }) - await render('?tab=people') + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { credentialGroup: null }, + error: null, + }) + await render('?tab=people&integration=jira') expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) expect(container.textContent).toContain('before requesting connections') expect(container.textContent).not.toContain('Organization accounts not configured') @@ -272,7 +298,11 @@ describe('organization integration invitations', () => { }) it('sends an org without a credential group back to provider setup before invitations', async () => { - mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null }) + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { credentialGroup: null }, + error: null, + }) await render('?tab=people') expect(container.textContent).toContain('before requesting connections') expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) @@ -284,6 +314,7 @@ describe('organization integration invitations', () => { it('surfaces account lookup errors instead of treating them as missing setup', async () => { mocks.accounts.mockReturnValue({ + isSuccess: true, error: new Error('Account access denied'), refetch: mocks.refetch, }) @@ -304,4 +335,105 @@ describe('organization integration invitations', () => { expect(mocks.people).not.toHaveBeenCalled() expect(mocks.invite).not.toHaveBeenCalled() }) + it('opens organization stats without loading people', async () => { + await render() + await click('Stats') + expect(container.textContent).toContain('Stats for org-a') + expect(mocks.people).not.toHaveBeenCalled() + }) + + it('filters connection summaries and requests to the selected integration, then returns to All', async () => { + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { + credentialGroup: { + id: 'group-a', + options: [ + { id: 'jira-option', provider: 'jira', status: 'active' }, + { id: 'gmail-option', provider: 'gmail', status: 'active' }, + { id: 'old-option', provider: 'confluence', status: 'revoked' }, + ], + }, + }, + }) + await render('?tab=people&integration=jira&credential-group-people=alex') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', 'alex', { + enabled: true, + optionId: 'jira-option', + }) + expect(findButton('Filter people by integration').textContent).toContain('Jira') + await click('Request connections') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Request Jira connections' + ) + await click('Cancel') + await act(async () => + findButton('Filter people by integration').dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }) + ) + ) + const all = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === 'All integrations' + ) + expect(all).toBeDefined() + expect(document.querySelector('[role="menu"]')?.textContent).not.toContain('Confluence') + await act(async () => all?.click()) + await vi.waitFor(() => + expect(mocks.people).toHaveBeenLastCalledWith('org-a', 'alex', { enabled: true }) + ) + expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') + }) + + it.each(['', '&integration=gmail'])( + 'defaults to All on navigation with one integration and initial filter %s', + async (filter) => { + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { + credentialGroup: { + id: 'group-a', + options: [{ id: 'gmail-option', provider: 'gmail', status: 'active' }], + }, + }, + }) + await render(`?tab=people${filter}`) + expect(findButton('Filter people by integration').textContent).toContain( + filter ? 'Gmail' : 'All integrations' + ) + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { + enabled: true, + ...(filter ? { optionId: 'gmail-option' } : {}), + }) + await click('Sources') + await click('People') + expect(findButton('Filter people by integration').textContent).toContain('All integrations') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) + } + ) + + it('preserves Slack setup recovery in People without hiding existing connections', async () => { + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { + credentialGroup: { + id: 'group-a', + options: [ + { + id: 'slack-option', + provider: 'slack', + status: 'active', + configurationStatus: 'needs_update', + }, + ], + }, + }, + }) + await render('?tab=people&integration=slack') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { + enabled: true, + optionId: 'slack-option', + }) + expect(findButton('Request connections')).toBeDisabled() + expect(container.textContent).toContain('Update the Slack app from Sources') + }) }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx index 5a88385585a..4753f09eb96 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -2,17 +2,21 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipModalError, ChipSwitch, toast } from '@sim/emcn' -import { useQueryState } from 'nuqs' +import { useQueryStates } from 'nuqs' import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' -import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { OrganizationSourcePeople } from '@/app/o/[organizationId]/settings/components/integrations/organization-source-people' +import { OrganizationSourceStats } from '@/app/o/[organizationId]/settings/components/integrations/organization-source-stats' +import { + organizationIntegrationsTabParam, + organizationPeopleIntegrationParam, +} from '@/app/o/[organizationId]/settings/components/integrations/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' import { useOrganizationAccounts, useUpdateOrganizationAccounts, @@ -20,10 +24,10 @@ import { export function OrganizationIntegrationsSettings() { const { organization, viewer } = useOrganizationContext() - const [tab, setTab] = useQueryState( - organizationIntegrationsTabParam.key, - organizationIntegrationsTabParam.parser - ) + const [{ tab }, setNavigation] = useQueryStates({ + [organizationIntegrationsTabParam.key]: organizationIntegrationsTabParam.parser, + [organizationPeopleIntegrationParam.key]: organizationPeopleIntegrationParam.parser, + }) const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined) const update = useUpdateOrganizationAccounts() const [refreshOpen, setRefreshOpen] = useState(false) @@ -39,58 +43,68 @@ export function OrganizationIntegrationsSettings() { { onSuccess: () => { setRefreshOpen(false) - toast.success('Connection settings refreshed') + toast.success('Sign-in settings updated') }, } ) } if (!viewer.isAdmin) return null + const tabs = ( + void setNavigation({ tab: value, integration: null })} + options={[ + { value: 'providers', label: 'Sources' }, + { value: 'people', label: 'People' }, + { value: 'stats', label: 'Stats' }, + ]} + /> + ) + return (
-
- void setTab(value)} - options={[ - { value: 'providers', label: 'Sources' }, - { value: 'people', label: 'People' }, - ]} - /> - {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( - { - update.reset() - setRefreshOpen(true) + {tab === 'providers' && ( +
+ {tabs} + {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( + { + update.reset() + setRefreshOpen(true) + }, }, - }, - ]} - /> - )} -
+ ]} + /> + )} +
+ )} { if (!update.isPending) setRefreshOpen(open) }} - title='Refresh connection settings?' - text='Apply the latest sign-in settings to all integrations. Affected accounts will need to reconnect.' - confirm={{ label: 'Refresh', pending: update.isPending, onClick: refreshConnections }} + title='Update sign-in settings?' + text='Apply Sim’s current OAuth app and permission settings to member connections. People whose settings changed must reconnect. This does not sync content.' + confirm={{ label: 'Update', pending: update.isPending, onClick: refreshConnections }} > {update.error?.message} {tab === 'providers' && } + {tab === 'stats' && } {tab === 'people' && ( - Add a source that uses member accounts before requesting connections. - void setTab('providers')}>View sources + void setNavigation({ tab: 'providers', integration: null })}> + View sources +
) : undefined } diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx index 9c1f75f361e..bb3eab8e511 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx @@ -8,6 +8,9 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/know const mocks = vi.hoisted(() => ({ admin: true, + memberAccess: true, + mirroredAccess: true, + updateUrl: vi.fn(), overview: vi.fn(), push: vi.fn(), add: vi.fn(), @@ -25,7 +28,7 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: () => ({ organization: { id: 'org-one' }, viewer: { isAdmin: mocks.admin }, - searchAccess: { memberScoped: true, sourceMirrored: true }, + searchAccess: { memberScoped: mocks.memberAccess, sourceMirrored: mocks.mirroredAccess }, }), })) vi.mock('@/hooks/queries/kb/connectors', () => ({ useOrganizationSearchOverview: mocks.overview })) @@ -73,6 +76,8 @@ let container: HTMLDivElement beforeEach(() => { vi.clearAllMocks() mocks.admin = true + mocks.memberAccess = true + mocks.mirroredAccess = true mocks.pending = false mocks.mutationError = null mocks.add.mockReset() @@ -83,6 +88,7 @@ beforeEach(() => { ['confluence', true], ['google-drive', true], ['google-email', true], + ['jira', true], ]), isIntegrationAvailabilityReady: true, refetchIntegrationAvailability: mocks.refetch, @@ -110,7 +116,7 @@ afterEach(async () => { async function render(searchParams = '') { await act(async () => root.render( - + @@ -128,253 +134,280 @@ async function click(label: string) { await act(async () => button!.click()) } -describe('organization integration management entry', () => { - it('uses Sources terminology in search and its empty state', async () => { - await render('?search=not-a-real-source') - expect(container.querySelector('input[placeholder="Search sources..."]')).toHaveValue( - 'not-a-real-source' - ) - expect(container.textContent).toContain('No matching sources') - expect(container.textContent).not.toContain('No matching integrations') - }) - it('offers Drive account management before anyone has connected', async () => { - mocks.overview.mockReturnValue({ - data: { - providers: [ - { - connectorType: 'google_drive', - approved: true, - sourceCount: 0, - status: 'waiting_for_connections', - issue: null, - isSyncing: false, - }, - ], - }, - isPending: false, +async function expectSetup(type: string | null, access: string | null = null) { + await act(async () => { + await vi.waitFor(() => { + const params = mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams + expect(params).toBeDefined() + expect(params.get('addConnector')).toBe(type) + expect(params.get('source-access')).toBe(access) }) - await render() - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( - 'href', - '/o/org-one/settings/integrations/providers/google_drive' - ) - expect(document.querySelector('a[aria-label="Set up Google Drive"]')).toBeNull() - expect(container.textContent).toContain('Waiting for connections') }) - it('shows the stable catalog with switches and separate setup and management links', async () => { +} + +async function fillPicker(value: string) { + const input = document.querySelector('[aria-label="Find a source"]') + expect(input).not.toBeNull() + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input?.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('organization integration management entry', () => { + it('uses navigable settings rows for added sources without switches or setup buttons', async () => { await render() - expect(document.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).toContain('Deactivated · 2 connections') + expect(container.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( 'href', '/o/org-one/settings/integrations/providers/gmail' ) - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( - 'href', - '/o/org-one/settings/integrations/providers/google_drive' - ) - expect(container.textContent).toContain('Waiting for connections') - expect(container.textContent).not.toContain('Needs setup') - expect(container.textContent).toContain('Disabled') - expect(container.textContent).toContain('Confluence') - expect(container.textContent).not.toContain('Add integration') - expect(document.querySelector('[aria-label="Allow Gmail in Sim Search"]')).toHaveAttribute( - 'aria-checked', - 'true' - ) - expect( - document.querySelector('[aria-label="Allow Google Drive in Sim Search"]') - ).toHaveAttribute('aria-checked', 'false') - expect(document.querySelector('a[aria-label="Set up Confluence"]')).toBeNull() - }) - - it('offers the catalog for a new organization without a modal or writes', async () => { - mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) - await render() - expect(container.textContent).toContain('Confluence') - expect(container.textContent).toContain('Google Drive') - expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() + expect(container.textContent).not.toContain('Confluence') + expect(container.querySelector('[role="switch"]')).toBeNull() + expect(container.querySelector('a[aria-label="Set up Gmail"]')).toBeNull() expect(mocks.add).not.toHaveBeenCalled() }) - it('enables in place and shows setup after the server confirms approval', async () => { - await render() - const row = document.querySelector('[aria-label="Allow Confluence in Sim Search"]') - await click('Allow Confluence in Sim Search') - expect(mocks.add).toHaveBeenCalledExactlyOnceWith( - { organizationId: 'org-one', connectorType: 'confluence', approved: true }, - expect.any(Object) - ) - expect(row).toHaveAttribute('aria-checked', 'false') + it('keeps approved sources with unfinished setup visible', async () => { mocks.overview.mockReturnValue({ data: { providers: [ - ...providers, { + ...providers[0], connectorType: 'confluence', - approved: true, - sourceCount: 0, status: 'needs_setup', - isSyncing: false, }, ], }, isPending: false, }) await render() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBe(row) - expect(row).toHaveAttribute('aria-checked', 'true') - expect(document.querySelector('a[aria-label="Set up Confluence"]')).not.toBeNull() - expect(mocks.push).not.toHaveBeenCalled() - expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.textContent).toContain('Setup required') + expect(container.querySelector('a[aria-label="Manage Confluence"]')).not.toBeNull() }) - it('requires confirmation before disabling existing sources and lets the admin cancel', async () => { - mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true }] }, - isPending: false, - }) + it('opens the catalog from the header without changing any approvals', async () => { + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) await render() - await click('Allow Google Drive in Sim Search') - expect(document.querySelector('[role="dialog"]')).toHaveTextContent( - 'Sources and connected accounts are preserved.' - ) + expect(container.textContent).toContain('No sources yet. Add a source to get started.') + expect(document.querySelector('[role="dialog"]')).toBeNull() + await click('Add source') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Confluence') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Jira') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Connect a service account') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Connect member accounts') expect(mocks.add).not.toHaveBeenCalled() await click('Cancel') expect(document.querySelector('[role="dialog"]')).toBeNull() + await expectSetup(null) expect(mocks.add).not.toHaveBeenCalled() - await click('Allow Google Drive in Sim Search') - mocks.add.mockImplementation((_input, options) => options.onSuccess()) - await click('Deactivate') + }) + + it('approves a new source before opening its central configuration form', async () => { + await render('?addConnector=') + await click('Set up Confluence') expect(mocks.add).toHaveBeenCalledExactlyOnceWith( - { organizationId: 'org-one', connectorType: 'google_drive', approved: false }, + { organizationId: 'org-one', connectorType: 'confluence', approved: true }, expect.any(Object) ) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + await act(async () => mocks.add.mock.calls[0][1].onSuccess()) + await expectSetup('confluence') expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('allows disabling an integration with no sources in place', async () => { - await render() - await click('Allow Gmail in Sim Search') - expect(mocks.add).toHaveBeenCalledExactlyOnceWith( - { organizationId: 'org-one', connectorType: 'gmail', approved: false }, + it('opens an approved source directly without a duplicate approval', async () => { + await render('?addConnector=&search=retained') + await click('Set up Gmail') + await expectSetup('gmail') + expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('search')).toBe('retained') + expect(mocks.add).not.toHaveBeenCalled() + }) + + it.each(['jira', 'confluence'])( + 'uses member setup for %s when central indexing is unavailable', + async (type) => { + mocks.mirroredAccess = false + mocks.add.mockImplementation((_input, options) => options.onSuccess()) + await render('?addConnector=') + await click(`Set up ${type === 'jira' ? 'Jira' : 'Confluence'}`) + await expectSetup(type, 'members') + } + ) + + it('routes Slack into the existing member setup with its custom-app step', async () => { + mocks.availability.mockReturnValue({ + integrationAvailability: new Map([['slack_v2', { state: 'limited' }]]), + oauthServiceAvailability: new Map(), + isIntegrationAvailabilityReady: true, + }) + mocks.add.mockImplementation((_input, options) => options.onSuccess()) + await render('?addConnector=') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Set up your Slack app') + await click('Set up Slack') + await expectSetup('slack', 'members') + }) + + it('reactivates a retained source before opening another configuration', async () => { + mocks.add.mockImplementation((_input, options) => options.onSuccess()) + await render('?addConnector=') + await click('Set up Google Drive') + await expectSetup('google_drive') + expect(mocks.add).toHaveBeenCalledWith( + { organizationId: 'org-one', connectorType: 'google_drive', approved: true }, expect.any(Object) ) - expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('keeps the confirmed state and reports failed approval changes', async () => { + it('keeps the picker open and toasts a failed approval for retry', async () => { mocks.add.mockImplementation((_input, options) => - options.onError(new Error('Could not update integration')) + options.onError(new Error('Could not add source')) ) - await render() - await click('Allow Confluence in Sim Search') - expect(toast.error).toHaveBeenCalledWith('Could not update integration') - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toHaveAttribute( - 'aria-checked', - 'false' - ) - expect(document.querySelector('a[aria-label="Set up Confluence"]')).toBeNull() + await render('?addConnector=') + await click('Set up Confluence') + expect(toast.error).toHaveBeenCalledWith('Could not add source') + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(mocks.updateUrl).not.toHaveBeenCalled() }) - it('keeps a failed deactivation in the confirmation dialog for retry', async () => { - mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true }] }, - isPending: false, + it('prevents another selection or dismissal while approval is pending', async () => { + mocks.pending = true + await render('?addConnector=') + expect(document.querySelector('button[aria-label="Set up Confluence"]')).toBeNull() + expect(document.querySelector('[aria-label="Find a source"]')).toBeDisabled() + await click('Cancel') + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(mocks.add).not.toHaveBeenCalled() + expect(mocks.updateUrl).not.toHaveBeenCalled() + }) + + it('explains unavailable providers in the picker without allowing setup', async () => { + mocks.availability.mockReturnValue({ + integrationAvailability: new Map(), + oauthServiceAvailability: new Map(), + isIntegrationAvailabilityReady: true, }) - await render() - await click('Allow Google Drive in Sim Search') - await click('Deactivate') - mocks.mutationError = new Error('Could not deactivate integration') - await render() + await render('?addConnector=') expect(document.querySelector('[role="dialog"]')).toHaveTextContent( - 'Could not deactivate integration' + 'Unavailable in this deployment' ) - expect( - document.querySelector('[aria-label="Allow Google Drive in Sim Search"]') - ).toHaveAttribute('aria-checked', 'true') - }) - - it('prevents duplicate changes while a mutation is pending', async () => { - mocks.pending = true - await render() - await click('Allow Confluence in Sim Search') + expect(document.querySelector('button[aria-label="Set up Confluence"]')).toBeNull() expect(mocks.add).not.toHaveBeenCalled() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBeDisabled() }) - it('blocks unavailable setup while keeping existing sources manageable and deactivatable', async () => { + it('keeps existing unavailable sources manageable', async () => { mocks.availability.mockReturnValue({ integrationAvailability: new Map(), oauthServiceAvailability: new Map(), isIntegrationAvailabilityReady: true, }) + await render() + expect(container.querySelector('a[aria-label="Manage Gmail"]')).not.toBeNull() + expect(container.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() + }) + + it('filters the picker independently from the main source list and resets when reopened', async () => { + await render('?search=gmail') + await click('Add source') + await fillPicker('confluence') + const dialog = document.querySelector('[role="dialog"]') + expect(dialog).toHaveTextContent('Confluence') + expect(dialog).not.toHaveTextContent('Google Drive') + expect(container.querySelector('a[aria-label="Manage Gmail"]')).not.toBeNull() + await fillPicker('no-such-source') + expect(dialog).toHaveTextContent('No matching sources') + await click('Cancel') + await click('Add source') + expect(document.querySelector('[aria-label="Find a source"]')).toHaveValue('') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Google Drive') + expect(mocks.add).not.toHaveBeenCalled() + }) + + it('keeps source health visible alongside the configuration count', async () => { mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true }] }, + data: { + providers: [ + { + ...providers[1], + approved: true, + status: 'needs_attention', + issue: 'sync_failed', + }, + ], + }, isPending: false, }) await render() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBeDisabled() - expect( - document.querySelector('[aria-label="Allow Google Drive in Sim Search"]') - ).not.toBeDisabled() - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() - expect(container.textContent).toContain('Unavailable in this deployment') - await click('Allow Confluence in Sim Search') - expect(mocks.add).not.toHaveBeenCalled() + expect(container.textContent).toContain('Sync failed · 2 connections') }) - it('fails closed while availability loads and offers retry if it fails', async () => { + it('fails closed and offers retry when availability fails inside the picker', async () => { mocks.availability.mockReturnValue({ integrationAvailability: new Map(), oauthServiceAvailability: new Map(), - isIntegrationAvailabilityReady: false, + isIntegrationAvailabilityReady: true, integrationAvailabilityError: new Error('Could not load connection availability'), refetchIntegrationAvailability: mocks.refetch, }) - await render() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBeDisabled() + await render('?addConnector=') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent( + 'Could not load connection availability' + ) + expect(document.querySelector('button[aria-label="Set up Confluence"]')).toBeNull() await click('Try again') expect(mocks.refetch).toHaveBeenCalledOnce() }) - it('does not hide actionable sync failures behind a source count', async () => { - mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true, status: 'needs_attention' }] }, - isPending: false, - }) - await render() - expect(container.textContent).toContain('Sync failed') - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() - }) - - it('does not turn a failed overview into unapproved switches', async () => { + it('does not turn a failed overview into an empty source list', async () => { mocks.overview.mockReturnValue({ error: new Error('Could not load sources'), isError: true, refetch: mocks.refetch, }) await render() - expect(document.querySelector('[role="switch"]')).toBeNull() + expect(container.textContent).not.toContain('No sources yet') + expect( + Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Add source' + ) + ).toBeDisabled() await click('Try again') expect(mocks.refetch).toHaveBeenCalledOnce() }) - it('keeps loading distinct from unapproved integrations', async () => { + it('keeps loading distinct from a new organization', async () => { mocks.overview.mockReturnValue({ isPending: true }) await render() expect(container.textContent).toContain('Loading sources') - expect(document.querySelector('[role="switch"]')).toBeNull() + expect(container.textContent).not.toContain('No sources yet') + expect( + Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Add source' + ) + ).toBeDisabled() }) it('does not expose admin controls or load admin data for members', async () => { mocks.admin = false - await render() + await render('?addConnector=') expect(mocks.overview).toHaveBeenLastCalledWith('org-one', { enabled: false }) expect(container.textContent).toBe('') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('does not expose setup when Search is disabled', async () => { + mocks.memberAccess = false + mocks.mirroredAccess = false + await render('?addConnector=') + expect(container.textContent).toContain('Search sources are not enabled') + expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('filters the whole catalog without changing approvals', async () => { - await render('?search=confluence') - expect(container.textContent).toContain('Confluence') + it('filters added sources without changing approvals', async () => { + await render('?search=gmail') + expect(container.textContent).toContain('Gmail') expect(container.textContent).not.toContain('Google Drive') expect(mocks.add).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx index a3de964ef15..359388941bd 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx @@ -1,19 +1,21 @@ 'use client' -import { useState } from 'react' -import { ChipConfirmModal, ChipLink, ChipModalError, Switch, toast } from '@sim/emcn' +import { toast } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' -import { - canConnectWithDefaults, - getConnectorAccessAvailability, - SEARCH_SOURCE_TYPES, -} from '@/lib/sim-search/connectors' +import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { AddOrganizationSourceModal } from '@/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' +import { + searchSetupAccessParam, + searchSetupParam, +} from '@/app/workspace/[workspaceId]/search/search-params' import { SettingsEmptyState, SettingsQueryErrorState, @@ -30,25 +32,62 @@ import { usePermissionConfig } from '@/hooks/use-permission-config' export function OrganizationIntegrationsSetup() { const { organization, viewer, searchAccess } = useOrganizationContext() const [search, setSearch] = useSettingsSearch() + const [setup, setSetup] = useQueryStates( + { + [searchSetupParam.key]: searchSetupParam.parser, + [searchSetupAccessParam.key]: searchSetupAccessParam.parser, + }, + { history: 'replace' } + ) const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) const availability = usePermissionConfig() const approval = useUpdateSearchIntegration() - const [deactivating, setDeactivating] = useState(null) const providers = new Map( overview.data?.providers.map((provider) => [provider.connectorType, provider]) ) + const sources = SEARCH_SOURCE_TYPES.map(([type, meta]) => ({ + type, + meta, + access: getConnectorAccessAvailability(meta, availability.integrationAvailability, { + memberAccessAvailable: searchAccess.memberScoped, + mirroredAccessAvailable: searchAccess.sourceMirrored, + oauthServiceAvailability: availability.oauthServiceAvailability, + isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + }), + })) const query = search.trim().toLowerCase() - const visible = SEARCH_SOURCE_TYPES.filter(([, meta]) => meta.name.toLowerCase().includes(query)) - const deactivatingName = SEARCH_SOURCE_TYPES.find(([type]) => type === deactivating)?.[1].name - const changeApproval = (connectorType: string, approved: boolean) => { - approval.reset() - if (!approved && (providers.get(connectorType)?.sourceCount ?? 0) > 0) { - setDeactivating(connectorType) + const visible = sources.flatMap((source) => { + const provider = providers.get(source.type) + return provider && + (provider.approved || provider.sourceCount > 0) && + source.meta.name.toLowerCase().includes(query) + ? [{ ...source, provider }] + : [] + }) + const ready = Boolean( + !overview.isPending && + !overview.isError && + availability.isIntegrationAvailabilityReady && + !availability.integrationAvailabilityError + ) + const closePicker = () => { + if (!approval.isPending) void setSetup({ addConnector: null, 'source-access': null }) + } + const selectSource = (type: string, accessMode: 'admin' | 'members') => { + const selectedType = searchSetupParam.parser.parse(type) + if (!selectedType || !ready || approval.isPending) return + const startSetup = () => + void setSetup({ + addConnector: selectedType, + 'source-access': accessMode === 'members' ? 'members' : null, + }) + if (providers.get(type)?.approved) { + startSetup() return } approval.mutate( - { organizationId: organization.id, connectorType, approved }, - { onError: (error) => toast.error(error.message) } + { organizationId: organization.id, connectorType: type, approved: true }, + { onSuccess: startSetup, onError: (error) => toast.error(error.message) } ) } if (!viewer.isAdmin) return null @@ -59,113 +98,94 @@ export function OrganizationIntegrationsSetup() { ) + const feedback = overview.isError ? ( + void overview.refetch()} + variant='inline' + /> + ) : availability.integrationAvailabilityError ? ( + void availability.refetchIntegrationAvailability()} + variant='inline' + /> + ) : null + return ( - - {availability.integrationAvailabilityError && ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - )} -
- {overview.isError ? ( - void overview.refetch()} - variant='inline' - /> - ) : overview.isPending ? ( - Loading sources… - ) : visible.length === 0 ? ( - No matching sources - ) : ( - visible.map(([type, meta]) => { - const provider = providers.get(type) - const approved = provider?.approved === true - const sourceCount = provider?.sourceCount ?? 0 - const access = getConnectorAccessAvailability( - meta, - availability.integrationAvailability, - { - memberAccessAvailable: searchAccess.memberScoped, - mirroredAccessAvailable: searchAccess.sourceMirrored, - oauthServiceAvailability: availability.oauthServiceAvailability, - isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, - } - ) - const available = access.admin || access.members - const hasSources = sourceCount > 0 - const manage = hasSources || canConnectWithDefaults(meta) - let description = provider ? organizationSearchStatusLabel(provider) : undefined - if (!hasSources && availability.isIntegrationAvailabilityReady && !available) - description = 'Unavailable in this deployment' - return ( - } - title={meta.name} - description={description} - trailing={ -
- {(hasSources || (approved && available)) && ( - - {manage ? 'Manage' : 'Set up'} - - )} - changeApproval(type, checked)} - /> -
- } - /> - ) - }) - )} -
- { - if (!open && !approval.isPending) setDeactivating(null) - }} - title={`Deactivate ${deactivatingName ?? 'integration'}?`} - text='Its content will be unavailable in Search, Assistant, and MCP. Sources and connected accounts are preserved.' - confirm={{ - label: 'Deactivate', - variant: 'destructive', - pending: approval.isPending, - onClick: () => { - if (!deactivating) return - approval.mutate( - { organizationId: organization.id, connectorType: deactivating, approved: false }, - { onSuccess: () => setDeactivating(null) } - ) + <> + void setSetup({ addConnector: '', 'source-access': null }), }, - }} + ]} + search={{ value: search, onChange: setSearch, placeholder: 'Search sources...' }} > - {approval.error?.message} - - + {setup.addConnector !== '' && feedback} +
+ {overview.isError ? null : overview.isPending ? ( + Loading sources… + ) : visible.length === 0 ? ( + + {query ? 'No matching sources' : 'No sources yet. Add a source to get started.'} + + ) : ( + visible.map(({ type, meta, access, provider }) => { + const available = access.admin || access.members + const status = + provider.approved && ready && !available + ? 'Unavailable in this deployment' + : organizationSearchStatusLabel(provider) + return ( + } + title={meta.name} + description={[ + status, + provider.sourceCount > 0 + ? `${provider.sourceCount} ${provider.sourceCount === 1 ? 'connection' : 'connections'}` + : undefined, + ] + .filter(Boolean) + .join(' · ')} + href={organizationRoutes(organization.id).searchProvider(type)} + clickLabel={`Manage ${meta.name}`} + navigable + /> + ) + }) + )} +
+
+ {setup.addConnector === '' ? ( + + ) : ( + + )} - + ) } diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.test.tsx new file mode 100644 index 00000000000..c5b62c0626c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.test.tsx @@ -0,0 +1,127 @@ +/** @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 { OrganizationSearchStatsPeriod } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period' + +let root: Root +let container: HTMLDivElement +let originalScroll: typeof HTMLElement.prototype.scrollIntoView +const onChange = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + originalScroll = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + HTMLElement.prototype.scrollIntoView = originalScroll + vi.unstubAllGlobals() +}) + +async function render() { + await act(async () => + root.render( + + ) + ) +} +async function selectPeriod(label: string) { + const trigger = container.querySelector('[role="combobox"]') + expect(trigger).not.toBeNull() + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + const option = Array.from(document.querySelectorAll('[role="option"]')).find( + (item) => item.textContent === label + ) + expect(option).toBeDefined() + await act(async () => option?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) +} +async function click(label: string) { + const button = Array.from(document.querySelectorAll('button')).find( + (item) => item.textContent === label + ) + expect(button).toBeDefined() + await act(async () => button?.click()) +} + +describe('Stats date selection', () => { + it('keeps the existing range when calendar edits are cancelled', async () => { + await render() + await selectPeriod('Custom range') + expect( + document + .querySelector('[data-radix-popper-content-wrapper]') + ?.contains(document.activeElement) + ).toBe(true) + await click('5') + await click('8') + expect(onChange).not.toHaveBeenCalled() + await click('Cancel') + await vi.waitFor(() => + expect(document.activeElement).toBe(container.querySelector('[role="combobox"]')) + ) + expect(onChange).not.toHaveBeenCalled() + expect(document.body.textContent).not.toContain('Apply') + expect(container.textContent).toContain('Sep 1 – Sep 3') + }) + it('applies both date-only boundaries in one update and closes the calendar', async () => { + await render() + await selectPeriod('Custom range') + await click('5') + await click('8') + await click('Apply') + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + period: 'custom', + startDate: '2024-09-05', + endDate: '2024-09-08', + }) + expect(document.body.textContent).not.toContain('Apply') + }) + it('clears custom boundaries when choosing a preset', async () => { + await render() + await selectPeriod('Past 3 days') + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + period: '3d', + startDate: null, + endDate: null, + }) + }) + it('opens the calendar from the keyboard and restores focus on Escape', async () => { + await render() + const trigger = container.querySelector('[role="combobox"]') + await act(async () => { + trigger?.focus() + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + }) + for (let index = 0; index < 6; index++) { + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + } + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(document.body.textContent).toContain('Apply') + expect(document.activeElement).not.toBe(trigger) + await act(async () => + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ) + ) + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)) + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.tsx new file mode 100644 index 00000000000..cff874b2785 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useRef, useState } from 'react' +import { Calendar, ChipCombobox, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn' +import { formatDateShort } from '@/lib/core/utils/date-display' +import { getSearchStatsRangeError, type SEARCH_STATS_PERIODS } from '@/lib/knowledge/search/stats' + +const PERIOD_OPTIONS = [ + { value: 'today', label: 'Today' }, + { value: '3d', label: 'Past 3 days' }, + { value: '7d', label: 'Past 7 days' }, + { value: '14d', label: 'Past 14 days' }, + { value: '30d', label: 'Past 30 days' }, + { value: '90d', label: 'Past 90 days' }, + { value: 'custom', label: 'Custom range' }, +] satisfies { value: (typeof SEARCH_STATS_PERIODS)[number]; label: string }[] + +interface SearchStatsPeriodSelection { + period: (typeof SEARCH_STATS_PERIODS)[number] + startDate: string | null + endDate: string | null +} + +interface OrganizationSearchStatsPeriodProps extends SearchStatsPeriodSelection { + onChange: (selection: SearchStatsPeriodSelection) => void +} + +export function OrganizationSearchStatsPeriod({ + period, + startDate, + endDate, + onChange, +}: OrganizationSearchStatsPeriodProps) { + const triggerContainerRef = useRef(null) + const calendarRef = useRef(null) + const [calendarOpen, setCalendarOpen] = useState(false) + const label = + period === 'custom' && startDate && endDate && !getSearchStatsRangeError({ startDate, endDate }) + ? `${formatDateShort(startDate)} – ${formatDateShort(endDate)}` + : PERIOD_OPTIONS.find((option) => option.value === period)?.label + + return ( +
+ { + const selected = PERIOD_OPTIONS.find((option) => option.value === value) + if (!selected) return + if (selected.value === 'custom') setCalendarOpen(true) + else onChange({ period: selected.value, startDate: null, endDate: null }) + }} + /> + + + calendarRef.current?.focus()} + onCloseAutoFocus={() => + triggerContainerRef.current?.querySelector('[role="combobox"]')?.focus() + } + > + setCalendarOpen(false)} + onRangeChange={(start, end) => { + const error = getSearchStatsRangeError({ startDate: start, endDate: end }) + if (error) { + toast.error(error) + return + } + onChange({ period: 'custom', startDate: start, endDate: end }) + setCalendarOpen(false) + }} + /> + + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts index dc1bfa33cfa..90b069f5f44 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts @@ -16,13 +16,13 @@ describe('organization source status labels', () => { it('describes the next step instead of calling all empty integrations unconfigured', () => { expect(organizationSearchStatusLabel(provider)).toBe('Waiting for connections') expect(organizationSearchStatusLabel({ ...provider, status: 'needs_setup' })).toBe( - 'Source not configured' + 'Setup required' ) expect( organizationSearchStatusLabel({ ...provider, status: 'needs_setup', sourceCount: 1 }) ).toBe('Waiting for first sync') expect(organizationSearchStatusLabel({ ...provider, status: 'active', sourceCount: 1 })).toBe( - 'Enabled' + 'Ready to search' ) }) it.each([ @@ -50,6 +50,6 @@ describe('organization source status labels', () => { status: 'needs_attention', issue: 'sync_failed', }) - ).toBe('Disabled') + ).toBe('Deactivated') }) }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts index 547f0339812..5988b08ce56 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts @@ -1,16 +1,16 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/knowledge/connectors' const STATUS_LABELS: Record = { - needs_setup: 'Source not configured', + needs_setup: 'Setup required', waiting_for_connections: 'Waiting for connections', indexing: 'Indexing', needs_attention: 'Sync failed', paused: 'Paused', - active: 'Enabled', + active: 'Ready to search', } export function organizationSearchStatusLabel(provider: OrganizationSearchProviderSummary): string { - if (!provider.approved) return 'Disabled' + if (!provider.approved) return 'Deactivated' if (provider.status === 'needs_setup' && provider.sourceCount > 0) return 'Waiting for first sync' if (provider.status === 'needs_attention') { const error = diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-people.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-people.tsx new file mode 100644 index 00000000000..338ba91c5d9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-people.tsx @@ -0,0 +1,79 @@ +'use client' + +import type { ComponentProps, ReactNode } from 'react' +import { ChipSelect } from '@sim/emcn' +import { useQueryState } from 'nuqs' +import type { CredentialGroupOption } from '@/lib/api/contracts/credential-groups' +import { getCredentialGroupIndexingConnector } from '@/lib/credential-groups/indexing' +import { organizationPeopleIntegrationParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' + +interface OrganizationSourcePeopleProps + extends Omit, 'searchConnection' | 'filters'> { + options: CredentialGroupOption[] + tabs: ReactNode +} + +export function OrganizationSourcePeople({ + options, + tabs, + ...props +}: OrganizationSourcePeopleProps) { + const [integration, setIntegration] = useQueryState( + organizationPeopleIntegrationParam.key, + organizationPeopleIntegrationParam.parser + ) + const integrations = options + .flatMap((option) => { + const connector = getCredentialGroupIndexingConnector(option.provider) + return option.status === 'active' && connector + ? [ + { + optionId: option.id, + type: connector.type, + name: connector.meta.name, + icon: connector.meta.icon, + needsSetup: option.provider === 'slack' && option.configurationStatus !== 'ready', + }, + ] + : [] + }) + .sort((a, b) => a.name.localeCompare(b.name)) + const selected = integrations.find((item) => item.type === integration) + + return ( + +
+ {tabs} + void setIntegration(value === 'all' ? null : value)} + disabled={props.enabled === false || Boolean(props.setupFallback)} + options={[ + { value: 'all', label: 'All integrations' }, + ...integrations.map((item) => ({ + value: item.type, + label: item.name, + icon: item.icon, + })), + ]} + /> +
+ {selected?.needsSetup && ( +

+ Update the Slack app from Sources before requesting connections. +

+ )} + + } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx new file mode 100644 index 00000000000..11481d6a1f4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx @@ -0,0 +1,152 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +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 { OrganizationSearchStats } from '@/lib/api/contracts/knowledge/search-stats' + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + chart: vi.fn(), + refetch: vi.fn(), + updateUrl: vi.fn(), +})) +vi.mock('@/hooks/queries/organization-search-stats', () => ({ + useOrganizationSearchStats: mocks.query, +})) +vi.mock('@/components/charts', () => ({ + BarChart: (props: unknown) => { + mocks.chart(props) + return
Daily chart
+ }, +})) +vi.mock('@/connectors/registry', () => ({ + CONNECTOR_META_REGISTRY: { confluence: { name: 'Confluence' }, jira: { name: 'Jira' } }, +})) +vi.mock('next/navigation', () => ({ useRouter: () => ({}) })) + +import { SettingsHeaderProvider } from '@/components/settings/settings-header' +import { OrganizationSourceStats } from '@/app/o/[organizationId]/settings/components/integrations/organization-source-stats' + +const data: OrganizationSearchStats = { + start: '2026-09-04T00:00:00.000Z', + end: '2026-09-10T20:00:00.000Z', + totals: { invocations: 5, activePeople: 2, results: 8 }, + series: [{ timestamp: '2026-09-10T00:00:00.000Z', invocations: 5 }], + sources: [{ sourceType: 'confluence', invocations: 4 }], + surfaces: [ + { surface: 'dashboard', invocations: 3 }, + { surface: 'mcp', invocations: 2 }, + ], + people: [ + { + userId: 'person', + name: 'Alex', + email: 'alex@example.com', + invocations: 5, + sourceTypes: ['confluence', 'jira'], + }, + ], +} +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.query.mockReturnValue({ data, isError: false, refetch: mocks.refetch }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) +async function render(searchParams = '') { + await act(async () => + root.render( + + + Sources People Stats} /> + + + ) + ) +} + +describe('organization Search stats', () => { + it('renders request metrics, source labels, people and the canonical daily chart', async () => { + await render() + expect(container.textContent).not.toContain('No-result rate') + expect(container.textContent).toContain('Confluence, Jira') + expect(container.textContent).toContain('alex@example.com') + expect(mocks.chart).toHaveBeenCalledWith( + expect.objectContaining({ data: [{ timestamp: data.series[0].timestamp, value: 5 }] }) + ) + expect(container.textContent).not.toMatch(/citations|clicks|agent runs/i) + }) + it('restores filters from the URL and scopes every metric request consistently', async () => { + await render('?stats-period=7d&stats-surface=mcp') + expect(mocks.query).toHaveBeenLastCalledWith({ + organizationId: 'org', + period: '7d', + surface: 'mcp', + }) + expect(container.textContent).not.toContain('Invocations by surface') + const filter = container.querySelector('button[aria-label="Search surface"]') + const toolbar = filter?.parentElement?.parentElement + expect(toolbar?.textContent).toContain('Sources People Stats') + }) + it('restores a custom UTC range with the surface filter', async () => { + await render( + '?stats-period=custom&stats-start=2026-09-01&stats-end=2026-09-03&stats-surface=slack' + ) + expect(mocks.query).toHaveBeenLastCalledWith({ + organizationId: 'org', + period: 'custom', + startDate: '2026-09-01', + endDate: '2026-09-03', + surface: 'slack', + }) + expect(container.textContent).toContain('Sep 1 – Sep 3') + }) + it('keeps the daily chart mounted with zero-valued buckets when there is no activity', async () => { + mocks.query.mockReturnValue({ + data: { + ...data, + totals: { invocations: 0, activePeople: 0, results: 0 }, + series: data.series.map((point) => ({ ...point, invocations: 0 })), + sources: [], + surfaces: [], + people: [], + }, + isError: false, + }) + await render() + expect(container.textContent).not.toContain('No recorded Search activity') + expect(container.querySelectorAll('dd')).toHaveLength(3) + expect(Array.from(container.querySelectorAll('dd'), (metric) => metric.textContent)).toEqual([ + '0', + '0', + '0', + ]) + expect(mocks.chart).toHaveBeenCalledWith( + expect.objectContaining({ + data: [{ timestamp: data.series[0].timestamp, value: 0 }], + }) + ) + }) + it('offers retry instead of displaying stale successful totals on failure', async () => { + mocks.query.mockReturnValue({ data, isError: true, refetch: mocks.refetch }) + await render() + expect(container.textContent).toContain('Couldn’t load Search stats') + expect(container.textContent).not.toContain('Search invocations') + const retry = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + await act(async () => retry?.click()) + expect(mocks.refetch).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx new file mode 100644 index 00000000000..1765187b12d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx @@ -0,0 +1,227 @@ +'use client' + +import { type ReactNode, useMemo } from 'react' +import { Chip, ChipSelect, Tooltip } from '@sim/emcn' +import { CircleInfo } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { BarChart } from '@/components/charts' +import { + SEARCH_STATS_PEOPLE_LIMIT, + SEARCH_STATS_SURFACE_LABELS, + SEARCH_STATS_SURFACES, +} from '@/lib/knowledge/search/stats' +import { OrganizationSearchStatsPeriod } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { + organizationSearchStatsParsers, + organizationSearchStatsUrlOptions, +} from '@/ee/organization-search-stats/search-params' +import { useOrganizationSearchStats } from '@/hooks/queries/organization-search-stats' + +const SURFACE_OPTIONS = [ + { value: 'all', label: 'All surfaces' }, + ...SEARCH_STATS_SURFACES.map((surface) => ({ + value: surface, + label: SEARCH_STATS_SURFACE_LABELS[surface], + })), +] + +interface OrganizationSourceStatsProps { + organizationId: string + tabs?: ReactNode +} + +function sourceLabel(sourceType: string) { + return ( + CONNECTOR_META_REGISTRY[sourceType]?.name ?? (sourceType === 'uploads' ? 'Uploads' : sourceType) + ) +} + +export function OrganizationSourceStats({ organizationId, tabs }: OrganizationSourceStatsProps) { + const [{ period, surface, startDate, endDate }, setFilters] = useQueryStates( + organizationSearchStatsParsers, + organizationSearchStatsUrlOptions + ) + const stats = useOrganizationSearchStats({ + organizationId, + period, + surface: surface ?? undefined, + ...(period === 'custom' + ? { startDate: startDate ?? undefined, endDate: endDate ?? undefined } + : {}), + }) + const series = useMemo( + () => + stats.data?.series.map((point) => ({ + timestamp: point.timestamp, + value: point.invocations, + })) ?? [], + [stats.data?.series] + ) + const data = stats.data + const totals = data?.totals + const metrics = totals + ? [ + { label: 'Search invocations', value: totals.invocations.toLocaleString() }, + { label: 'Active people', value: totals.activePeople.toLocaleString() }, + { label: 'Results returned', value: totals.results.toLocaleString() }, + ] + : [] + + return ( + +
+ {tabs} +
+ + void setFilters({ surface: organizationSearchStatsParsers.surface.parse(value) }) + } + /> + void setFilters(selection)} + /> +
+
+ {stats.isError ? ( + + Couldn’t load Search stats. void stats.refetch()}>Try again + + ) : !data || !totals ? ( + Loading Search stats… + ) : ( + <> +
+ {metrics.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+
+ ))} +
+ + + + + + Successful Search requests since tracking was enabled. Assistant and MCP counts + are Search tool calls. Results count each document once per request. One request + can return multiple sources. + + + } + action={ + + {period === 'custom' ? 'UTC' : 'UTC · Includes today'} + + } + > + + + {!surface && totals.invocations > 0 && ( + +
+ {data.surfaces.map((row) => ( + + {row.invocations.toLocaleString()} + + } + /> + ))} +
+
+ )} + + Invocations returning this source + + } + > + {data.sources.length ? ( +
+ {data.sources.map((row) => { + const Icon = CONNECTOR_META_REGISTRY[row.sourceType]?.icon + return ( + : undefined} + title={sourceLabel(row.sourceType)} + badge={ + + {row.invocations.toLocaleString()} + + } + /> + ) + })} +
+ ) : ( + + No sources returned in this period. + + )} +
+ + Top {SEARCH_STATS_PEOPLE_LIMIT} · Invocations + + } + > + {data.people.length ? ( +
+ {data.people.map((person) => ( + + {person.invocations.toLocaleString()} + + } + /> + ))} +
+ ) : ( + + No active people in this period. + + )} +
+ + )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts index 30d5f4c5561..ad97007ca37 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts @@ -1,8 +1,8 @@ -import { parseAsStringLiteral } from 'nuqs/server' +import { createSerializer, parseAsString, parseAsStringLiteral } from 'nuqs/server' export const organizationIntegrationsTabParam = { key: 'tab', - parser: parseAsStringLiteral(['providers', 'people']).withDefault('providers'), + parser: parseAsStringLiteral(['providers', 'people', 'stats']).withDefault('providers'), } as const export const connectedAccountsParam = { @@ -10,7 +10,14 @@ export const connectedAccountsParam = { parser: parseAsStringLiteral(['slack']), } as const -export const organizationProviderTabParam = { - key: 'view', - parser: parseAsStringLiteral(['sources', 'accounts']).withDefault('sources'), +/** An absent integration includes connections from all integrations. */ +export const organizationPeopleIntegrationParam = { + key: 'integration', + parser: parseAsString, } as const + +export const serializeOrganizationPeople = createSerializer({ + tab: organizationIntegrationsTabParam.parser, + integration: organizationPeopleIntegrationParam.parser, + 'credential-group-people': parseAsString, +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx index 2dff4d17b5f..10dac1eb1bb 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx @@ -24,8 +24,8 @@ export function OrganizationSlackAccountRemoval({ onOpenChange={(open) => { if (!open && !update.isPending) onClose() }} - title='Remove Slack account setup?' - text='This disconnects your organization’s Slack accounts and clears their saved app configuration. Remove any sources using these accounts first.' + title='Remove Slack app setup?' + text='This disconnects your organization’s Slack accounts and clears their saved app configuration. Remove any connections using these accounts first.' confirm={{ label: 'Remove', variant: 'destructive', diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx index 1cc0c9b77b9..eef37d52a2f 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx @@ -72,8 +72,57 @@ describe('organization Slack setup continuation', () => { it('prepares a missing container without asking the admin to make an extra choice', async () => { await render() expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' }) - expect(document.body.textContent).toContain('Loading Slack setup') - expect(document.body.textContent).not.toContain('Continue') + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.modal).not.toHaveBeenCalled() + mocks.prepare.mockReturnValue({ mutate: mocks.ensure, isIdle: false, isPending: true }) + await render() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.ensure).toHaveBeenCalledOnce() + mocks.prepare.mockReturnValue({ + mutate: mocks.ensure, + isIdle: false, + isPending: false, + data: { credentialGroup: { id: 'group-a', organizationId: 'org-a', options: [] } }, + }) + await render() + expect(mocks.modal).toHaveBeenCalledWith( + expect.objectContaining({ credentialGroupId: 'group-a' }), + undefined + ) + }) + + it('does not open a temporary modal while discovering accounts', async () => { + mocks.accounts.mockReturnValue({ isSuccess: false, isPending: true }) + await render() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.modal).not.toHaveBeenCalled() + }) + + it.each(['discovery', 'preparation'])('keeps %s errors recoverable', async (stage) => { + const retry = vi.fn() + if (stage === 'discovery') { + mocks.accounts.mockReturnValue({ + isSuccess: false, + error: new Error('Could not load Slack setup'), + refetch: retry, + }) + } else { + mocks.prepare.mockReturnValue({ + mutate: mocks.ensure, + isIdle: false, + error: new Error('Could not prepare Slack setup'), + }) + } + await render() + expect(document.body.textContent).toContain('Set up Slack app') + const retryButton = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + expect(retryButton).toBeDefined() + await act(async () => retryButton?.click()) + if (stage === 'discovery') expect(retry).toHaveBeenCalledOnce() + else expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' }) }) it('does not prepare accounts or open admin setup for an ordinary member', async () => { diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx index e5e46838ec2..37d11404811 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx @@ -15,10 +15,7 @@ import { searchSetupParam, searchSetupReturnParam, } from '@/app/workspace/[workspaceId]/search/search-params' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' import { useEnsureOrganizationAccounts, @@ -83,32 +80,30 @@ export function OrganizationSlackAccountSetup() { }} /> ) + const error = accounts.error ?? setupError + if (!error) return null return ( { if (!next) close() }} - srTitle='Connect Slack accounts' + srTitle='Set up Slack app' > - Connect Slack accounts + Set up Slack app - - {accounts.error || setupError ? ( - - accounts.error - ? void accounts.refetch() - : ensureAccounts({ organizationId: organization.id }) - } - variant='inline' - /> - ) : ( - Loading Slack setup… - )} + + + accounts.error + ? void accounts.refetch() + : ensureAccounts({ organizationId: organization.id }) + } + variant='inline' + /> diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx index 4e4b229507e..5c8679484e7 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx @@ -111,9 +111,11 @@ describe('Slack Search settings and shared wizard', () => { await render() expect(container.querySelectorAll('button')).toHaveLength(1) await click('Set up') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Create Slack app') + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1') expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('App manifest') expect(document.querySelector('a[href="https://api.slack.com/apps"]')).toHaveTextContent( - 'Create app in Slack' + 'Create app' ) expect(document.querySelectorAll('input')).toHaveLength(0) expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search') @@ -129,27 +131,82 @@ describe('Slack Search settings and shared wizard', () => { await render() await click('Set up') expect(document.querySelector('[role="alert"]')).toHaveTextContent('public HTTPS') - expect(button('Continue').disabled).toBe(true) + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1') + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Continue') await click('Retry') expect(mocks.refetch).toHaveBeenCalledOnce() expect(mocks.install).not.toHaveBeenCalled() }) + it.each(['shared', 'custom'] as const)( + 'waits for preparation before showing the %s setup path', + async (mode) => { + mocks.manifest.mockReturnValue({ + data: undefined, + isPending: true, + refetch: mocks.refetch, + }) + await render() + await click('Set up') + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1) + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Loading Slack setup') + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1') + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent( + 'Create your Slack app' + ) + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Install Sim Search') + expect(mocks.install).not.toHaveBeenCalled() + + mocks.manifest.mockReturnValue({ + data: { + manifest: '{}', + existingApp: null, + createAppUrl: 'https://api.slack.com/apps', + sharedAppId: mode === 'shared' ? 'A_SHARED' : null, + }, + isPending: false, + refetch: mocks.refetch, + }) + await render() + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1) + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Loading Slack setup') + if (mode === 'shared') { + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1') + await click('Install Sim Search') + expect(mocks.install).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org-1', mode: 'shared' }), + expect.any(Object) + ) + } else { + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Create Slack app') + await click('Continue') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Client ID') + expect(mocks.install).not.toHaveBeenCalled() + } + } + ) + it('reconnects the existing app and credential without offering duplicate setup', async () => { await render(true) expect(container.textContent).not.toContain('Set up') expect(container.textContent).toContain('Enabled') await action('Reconnect') - expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Reconnect Slack Search') - await click('Copy app configuration') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update Slack app') + expect(button('Continue')).toBeDisabled() + await click('Continue') + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Client ID') + await click('Copy configuration') + expect(button('Continue')).not.toBeDisabled() expect(mocks.copy).toHaveBeenCalledExactlyOnceWith('{}') expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull() expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Configuration copied') expect(document.querySelector('pre')).toBeNull() await click('Continue') - expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Leave fields blank') + expect( + document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]') + ).toHaveLength(3) await click('Continue') - await click('Install in Slack') + await click('Reconnect in Slack') expect(mocks.install).toHaveBeenCalledWith( expect.objectContaining({ installationId: 'installation-1', @@ -165,11 +222,12 @@ describe('Slack Search settings and shared wizard', () => { mocks.copy.mockRejectedValueOnce(new Error('Clipboard access denied')) await render(true) await action('Reconnect') - await click('Copy app configuration') + await click('Copy configuration') expect(document.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access') expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).toBeNull() - expect(button('Copy app configuration')).toBeDefined() - await click('Copy app configuration') + expect(button('Copy configuration')).toBeDefined() + expect(button('Continue')).toBeDisabled() + await click('Copy configuration') expect(document.querySelector('[role="alert"]')).toBeNull() expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull() }) @@ -186,8 +244,8 @@ describe('Slack Search settings and shared wizard', () => { }) await render() await click('Set up') - expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update your Slack app') - await click('Copy app configuration') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update Slack app') + await click('Copy configuration') expect(mocks.copy).toHaveBeenCalledExactlyOnceWith( '{"display_information":{"name":"Shared Slack app"}}' ) diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx index 7a6a069fde9..26426ebd5f2 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx @@ -73,7 +73,7 @@ export function OrganizationSearchSlack() { description='Connect your workspace to ask questions in Slack.' trailing={ setWizard({})}> - Set up + {installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'} } /> diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx index 25ff75fb3e8..bf09e5c23f9 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx @@ -1,105 +1,81 @@ /** @vitest-environment node */ -import { Suspense } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - session: vi.fn(), - authorize: vi.fn(), - redirect: vi.fn(), - notFound: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ session: vi.fn(), authorize: vi.fn(), redirect: vi.fn() })) vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) vi.mock('@/lib/settings/application/organization-section-access', () => ({ authorizeOrganizationSettingsSection: mocks.authorize, })) -vi.mock('@/lib/sim-search/connectors', () => ({ - SEARCH_SOURCE_TYPES: [['google_drive', { name: 'Google Drive' }]], +vi.mock('next/navigation', () => ({ + redirect: (url: string) => { + mocks.redirect(url) + throw new Error('redirect') + }, + notFound: () => { + throw new Error('not found') + }, })) -vi.mock('next/navigation', () => ({ redirect: mocks.redirect, notFound: mocks.notFound })) -vi.mock('@/components/settings/settings-header', () => ({ - SettingsHeaderProvider: 'header-provider', - SettingsHeaderShell: 'header-shell', +vi.mock('@/lib/sim-search/connectors', () => ({ + SEARCH_SOURCE_TYPES: [ + ['jira', { name: 'Jira' }], + ['confluence', { name: 'Confluence' }], + ], })) vi.mock( '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail', - () => ({ OrganizationProviderDetail: 'provider-detail' }) + () => ({ OrganizationProviderDetail: () => null }) ) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: () => null, +})) -import OrganizationProviderLayout from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/layout' -import OrganizationProviderPage, { - generateMetadata, -} from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page' - -const props = { - params: Promise.resolve({ organizationId: 'org-one', connectorType: 'google_drive' }), -} - -describe('organization provider page authorization', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.session.mockResolvedValue({ user: { id: 'admin-one' } }) - mocks.authorize.mockResolvedValue(true) - mocks.redirect.mockImplementation(() => { - throw new Error('redirect') - }) - mocks.notFound.mockImplementation(() => { - throw new Error('not found') - }) - }) +import OrganizationProviderPage from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page' - it('checks the target organization integration policy before rendering provider management', async () => { - const page = await OrganizationProviderPage(props) - expect(mocks.authorize).toHaveBeenCalledWith({ - organizationId: 'org-one', - userId: 'admin-one', - section: 'integrations', - }) - expect(page.type).toBe(Suspense) - expect(page.props.children).toMatchObject({ - type: 'provider-detail', - props: { connectorType: 'google_drive' }, - }) - }) +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'admin-1' } }) + mocks.authorize.mockResolvedValue(true) +}) - it('rejects unknown or unsupported providers before loading protected organization state', async () => { +it.each(['jira', 'confluence'])( + 'moves legacy %s Accounts links to filtered People and preserves the search', + async (connectorType) => { await expect( OrganizationProviderPage({ - params: Promise.resolve({ organizationId: 'org-one', connectorType: 'unknown-provider' }), + params: Promise.resolve({ organizationId: 'org-1', connectorType }), + searchParams: Promise.resolve({ + view: 'accounts', + 'credential-group-people': 'alex+qa@example.com', + }), }) - ).rejects.toThrow('not found') - expect(mocks.session).not.toHaveBeenCalled() - expect(mocks.authorize).not.toHaveBeenCalled() - }) - - it('does not render when the admin or feature gate denies access', async () => { - mocks.authorize.mockResolvedValue(false) - await expect(OrganizationProviderPage(props)).rejects.toThrow('not found') - }) - - it('returns signed-out users to the requested provider after login', async () => { - mocks.session.mockResolvedValue(null) - await expect(OrganizationProviderPage(props)).rejects.toThrow('redirect') - const url = new URL(mocks.redirect.mock.calls[0][0], 'https://example.com') - expect(url.pathname).toBe('/login') - expect(url.searchParams.get('callbackUrl')).toBe( - '/o/org-one/settings/integrations/providers/google_drive' - ) - expect(mocks.authorize).not.toHaveBeenCalled() - }) + ).rejects.toThrow('redirect') + const url = new URL(mocks.redirect.mock.lastCall![0], 'https://example.com') + expect(url.pathname).toBe('/o/org-1/settings/integrations') + expect(url.searchParams.get('tab')).toBe('people') + expect(url.searchParams.get('integration')).toBe(connectorType) + expect(url.searchParams.get('credential-group-people')).toBe('alex+qa@example.com') + expect(url.searchParams.has('view')).toBe(false) + } +) - it('keeps the settings header mounted independently of provider authorization', () => { - const layout = OrganizationProviderLayout({ children: Loading integration }) - expect(layout.type).toBe('header-provider') - expect(layout.props.children).toMatchObject({ - type: 'header-shell', - props: { meta: { title: 'Integration' } }, +it('authorizes organization settings before redirecting a legacy link', async () => { + mocks.authorize.mockResolvedValue(false) + await expect( + OrganizationProviderPage({ + params: Promise.resolve({ organizationId: 'org-1', connectorType: 'jira' }), + searchParams: Promise.resolve({ view: 'accounts' }), }) - expect(mocks.authorize).not.toHaveBeenCalled() - }) - - it('uses the supported provider name as page metadata', async () => { - expect(await generateMetadata(props)).toEqual({ title: 'Google Drive' }) - expect(mocks.session).not.toHaveBeenCalled() - expect(mocks.authorize).not.toHaveBeenCalled() - }) + ).rejects.toThrow('not found') + expect(mocks.redirect).not.toHaveBeenCalled() }) + +it.each(['jira', ''])( + 'preserves an active setup in a legacy Accounts link (%s)', + async (addConnector) => { + await OrganizationProviderPage({ + params: Promise.resolve({ organizationId: 'org-1', connectorType: 'jira' }), + searchParams: Promise.resolve({ view: 'accounts', addConnector, 'source-access': 'members' }), + }) + expect(mocks.redirect).not.toHaveBeenCalled() + } +) diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx index 8250da53e98..5f0762a0e49 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx @@ -6,11 +6,14 @@ import { organizationRoutes } from '@/lib/navigation/paths' import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' import { SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { serializeOrganizationPeople } from '@/app/o/[organizationId]/settings/components/integrations/search-params' import { OrganizationProviderDetail } from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail' +import { searchSetupParam } from '@/app/workspace/[workspaceId]/search/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' interface OrganizationProviderPageProps { params: Promise<{ organizationId: string; connectorType: string }> + searchParams: Promise> } export async function generateMetadata({ @@ -22,7 +25,10 @@ export async function generateMetadata({ } } -export default async function OrganizationProviderPage({ params }: OrganizationProviderPageProps) { +export default async function OrganizationProviderPage({ + params, + searchParams, +}: OrganizationProviderPageProps) { const { organizationId, connectorType } = await params if (!SEARCH_SOURCE_TYPES.some(([type]) => type === connectorType)) notFound() const session = await getSession() @@ -41,6 +47,26 @@ export default async function OrganizationProviderPage({ params }: OrganizationP })) ) notFound() + const query = await searchParams + const activeSetup = + typeof query.addConnector === 'string' + ? searchSetupParam.parser.parse(query.addConnector) + : null + if (query.view === 'accounts' && activeSetup === null) { + redirect( + serializeOrganizationPeople( + organizationRoutes(organizationId).settingsSection('integrations'), + { + tab: 'people', + integration: connectorType, + 'credential-group-people': + typeof query['credential-group-people'] === 'string' + ? query['credential-group-people'] + : null, + } + ) + ) + } return ( Loading integration…} diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx index 7dda89df3ff..4d77fb01677 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx @@ -66,6 +66,7 @@ vi.mock('@/connectors/registry', () => ({ auth: { mode: 'oauth', provider: 'google-calendar', adminCredentialType: 'service_account' }, }, slack: { name: 'Slack', auth: { mode: 'oauth', provider: 'slack' } }, + github: { name: 'GitHub', auth: { mode: 'oauth', provider: 'github-repositories' } }, gitlab: { name: 'GitLab', auth: { mode: 'apiKey' } }, }, })) @@ -116,6 +117,7 @@ vi.mock('@/app/o/[organizationId]/settings/components/integrations/slack-account import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { OrganizationProviderDetail } from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' const provider = { connectorType: 'google_drive', @@ -220,47 +222,45 @@ describe('organization provider management', () => { }) } + it.each(['members', 'admin'])( + 'shows GitHub repositories without an account method label for %s access', + async (accessMode) => { + mocks.overview.mockReturnValue({ + data: { providers: [{ ...provider, connectorType: 'github', sourceCount: 1 }] }, + }) + mocks.sources.mockReturnValue({ + data: [ + { ...source, connectorType: 'github', sourceDescription: 'acme/platform', accessMode }, + ], + isPending: false, + }) + await render('github') + expect(container.textContent).toContain('acme/platform') + expect(container.textContent).toContain('Last synced') + expect(container.textContent).not.toContain('Member accounts') + expect(container.textContent).not.toContain('Admin or service account') + expect( + container.querySelector('a[href="/o/org-one/settings/integrations/sources/source-one"]') + ).not.toBeNull() + } + ) + it.each(['gmail', 'google_calendar', 'google_drive'])( - 'lets %s wait for connections without requiring source setup', + 'offers configuration setup directly for %s without an Accounts or Advanced tab', async (connectorType) => { mocks.overview.mockReturnValue({ - data: { - providers: [ - { - connectorType, - approved: true, - status: 'waiting_for_connections', - sourceCount: 0, - issue: null, - isSyncing: false, - }, - ], - }, + data: { providers: [{ ...provider, connectorType, status: 'waiting_for_connections' }] }, }) - mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) mocks.sources.mockReturnValue({ data: [], isPending: false }) - await render(connectorType, '?view=accounts') + await render(connectorType) expect(container.textContent).toContain('Waiting for connections') expect(container.textContent).toContain( - 'Members connect their accounts from Integrations. Indexing starts automatically.' + `No ${CONNECTOR_META_REGISTRY[connectorType]?.name} connections yet.` ) - expect(container.textContent).not.toContain('Add source') - expect(container.textContent).not.toContain('Add sync configuration') - expect(container.querySelector('a[href="/o/org-one/integrations"]')).toBeNull() - expect(container.textContent).not.toContain('Open Integrations') - expect(mocks.sources).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: false }) - ) - await click('Advanced') - expect(container.textContent).toContain('No sync configurations yet.') - expect(container.textContent).toContain('Add sync configuration') - await click('Add sync configuration') + expect(container.querySelector('[role="radio"]')).toBeNull() + await click('Connect service account') await vi.waitFor(() => { - expect(mocks.updateUrl).toHaveBeenLastCalledWith( - expect.objectContaining({ searchParams: expect.any(URLSearchParams) }) - ) - expect(mocks.updateUrl.mock.calls.at(-1)![0].searchParams.get('addConnector')).toBe( + expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('addConnector')).toBe( connectorType ) }) @@ -284,7 +284,7 @@ describe('organization provider management', () => { expect(container.textContent).not.toContain( 'Members connect their accounts from Integrations.' ) - await click('Add sync configuration') + await click('Connect service account') await vi.waitFor(() => { const params = mocks.updateUrl.mock.calls.at(-1)![0].searchParams expect(params.get('addConnector')).toBe(connectorType) @@ -320,29 +320,17 @@ describe('organization provider management', () => { ])('opens configurations for the $name setup', async ({ modes }) => { withSources(modes) await render(connectorType) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - 'Advanced' - ) + expect(container.querySelector('[role="radio"]')).toBeNull() expect(mocks.sources).toHaveBeenLastCalledWith( expect.any(Object), expect.objectContaining({ enabled: true }) ) expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) - expect(container.textContent).toContain('Add sync configuration') + expect(container.textContent).toContain('Connect service account') if (modes.length === 0) - expect(container.textContent).toContain('No sync configurations yet.') - }) - - it.each(['accounts', 'sources'])('honors explicit %s links', async (view) => { - withSources(['admin']) - await render(connectorType, `?view=${view}`) - expect(mocks.sources).toHaveBeenLastCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: view === 'sources' }) - ) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - view === 'accounts' ? 'Accounts' : 'Advanced' - ) + expect(container.textContent).toContain( + `No ${CONNECTOR_META_REGISTRY[connectorType]?.name} connections yet.` + ) }) it('loads the configuration list in parallel with its overview and retains the default', async () => { @@ -357,9 +345,7 @@ describe('organization provider management', () => { ) withSources(['members']) await render(connectorType) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - 'Advanced' - ) + expect(container.querySelector('[role="radio"]')).toBeNull() expect(container.textContent).toContain('Engineering handbook') expect(mocks.sources).toHaveBeenLastCalledWith( expect.any(Object), @@ -367,43 +353,6 @@ describe('organization provider management', () => { ) expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) }) - - it('preserves an explicit Accounts choice when the overview changes', async () => { - withSources(['members']) - await render(connectorType) - await click('Accounts') - await vi.waitFor(() => - expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('view')).toBe('accounts') - ) - withSources(['admin']) - await render(connectorType) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - 'Accounts' - ) - expect(mocks.sources).toHaveBeenLastCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: false }) - ) - }) - - it.each([ - { accessMode: 'admin', method: 'Service account' }, - { accessMode: 'members', method: 'Member accounts' }, - ])( - 'identifies $method configurations without changing their title or destination', - async ({ accessMode, method }) => { - withSources([accessMode]) - mocks.sources.mockReturnValue({ - data: [{ ...source, connectorType, accessMode }], - isPending: false, - }) - await render(connectorType) - expect(container.textContent).toContain(`${method} · Last synced`) - expect( - container.querySelector('a[aria-label="Open Engineering handbook"]') - ).toHaveAttribute('href', '/o/org-one/settings/integrations/sources/source-one') - } - ) } ) @@ -412,7 +361,15 @@ describe('organization provider management', () => { async (status) => { withSlackAccounts(true, status) await render('slack') - await click('Remove account setup') + const headerRemove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Remove app setup' + ) + const deactivate = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Deactivate' + ) + expect(headerRemove?.className).toBe(deactivate?.className) + expect(headerRemove?.querySelector('[class*="text-error"]')).toBeNull() + await click('Remove app setup') expect(mocks.removeAccounts).not.toHaveBeenCalled() expect(document.querySelector('[role="dialog"]')).toHaveTextContent('saved app configuration') await click('Remove') @@ -434,7 +391,7 @@ describe('organization provider management', () => { it('offers removal when Slack is deactivated and allows cancelling without a mutation', async () => { withSlackAccounts(false) await render('slack') - await click('Remove account setup') + await click('Remove app setup') await click('Cancel') expect(document.querySelector('[role="dialog"]')).toBeNull() expect(mocks.removeAccounts).not.toHaveBeenCalled() @@ -444,32 +401,19 @@ describe('organization provider management', () => { withSlackAccounts() mocks.accountRemovalError = new Error('Remove the source using these accounts first.') await render('slack') - await click('Remove account setup') + await click('Remove app setup') await click('Remove') expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent( 'Remove the source using these accounts first.' ) }) - it('passes the removal action to the Slack Accounts tab header', async () => { - withSlackAccounts() - await render('slack', '?view=accounts') - const actions = mocks.people.mock.calls.at(-1)![0].panel.actions - expect(actions).toEqual([ - expect.objectContaining({ text: 'Remove account setup', onSelect: expect.any(Function) }), - ]) - await act(async () => actions[0].onSelect()) - expect(document.querySelector('[role="dialog"]')).toHaveTextContent( - 'Remove Slack account setup?' - ) - }) - it('keeps Slack cleanup available even when personal source creation is unavailable', async () => { withSlackAccounts() mocks.personal = false await render('slack') expect(mocks.accounts).toHaveBeenCalledWith('org-one') - await click('Remove account setup') + await click('Remove app setup') expect(document.querySelector('[role="dialog"]')).not.toBeNull() }) @@ -538,14 +482,12 @@ describe('organization provider management', () => { expect(container.textContent).not.toContain('Activate this integration') expect(mocks.people).not.toHaveBeenCalled() expect( - container.querySelector( - `input[placeholder="${params === '?view=accounts' ? 'Search people...' : 'Search sync configurations...'}"]` - ) + container.querySelector('input[placeholder="Search Google Drive connections..."]') ).toBeEnabled() } ) - it('hides cached account content and retries when overview access is revoked', async () => { + it('hides cached source content and retries when overview access is revoked', async () => { const refetch = vi.fn() mocks.overview.mockReturnValue({ data: { providers: [provider] }, @@ -553,21 +495,29 @@ describe('organization provider management', () => { error: new ApiClientError({ status: 403, message: 'Access denied', body: null }), refetch, }) - await render('google_drive', '?view=accounts&credential-group-people=alex') + await render('google_drive', '?search=alex') expect(container.textContent).toContain('Access denied') expect(mocks.people).not.toHaveBeenCalled() - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toHaveValue('alex') + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toBeEnabled() await click('Try again') expect(refetch).toHaveBeenCalledOnce() }) - it('offers activation from the accounts tab when an existing provider is deactivated', async () => { + it('offers activation when an existing provider is deactivated', async () => { mocks.overview.mockReturnValue({ data: { providers: [{ ...provider, approved: false }] } }) - await render('google_drive', '?view=accounts&credential-group-people=alex') + await render('google_drive', '?search=alex') expect(mocks.people).not.toHaveBeenCalled() - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toHaveValue('alex') + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toBeEnabled() await click('Activate') expect(mocks.activate).toHaveBeenCalledWith({ organizationId: 'org-one', @@ -576,7 +526,7 @@ describe('organization provider management', () => { }) }) - it('shows activation failures on Accounts and keeps activation available for retry', async () => { + it('shows activation failures and keeps activation available for retry', async () => { mocks.overview.mockReturnValue({ data: { providers: [{ ...provider, approved: false }] } }) mocks.approvalError = new Error('Activation could not be saved') await render('google_drive', '?view=accounts') @@ -590,56 +540,6 @@ describe('organization provider management', () => { }) }) - it.each([ - ['loading', 'Loading accounts…'], - ['error', 'Accounts unavailable'], - [ - 'missing group', - 'Members connect their accounts from Integrations. Indexing starts automatically.', - ], - [ - 'missing provider option', - 'Members connect their accounts from Integrations. Indexing starts automatically.', - ], - ])('preserves Accounts search while %s', async (state, message) => { - const refetch = vi.fn() - mocks.accounts.mockReturnValue( - state === 'loading' - ? { isPending: true } - : state === 'error' - ? { isError: true, error: new Error(message), refetch } - : { - data: { - credentialGroup: - state === 'missing group' ? null : { ...credentialGroup, options: [] }, - }, - isPending: false, - } - ) - await render('google_drive', '?view=accounts&credential-group-people=alex&search=handbook') - - expect(container.textContent).toContain(message) - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() - expect(container.querySelector('input[placeholder="Search sync configurations..."]')).toBeNull() - expect(mocks.people).not.toHaveBeenCalled() - if (state === 'error') { - await click('Try again') - expect(refetch).toHaveBeenCalledOnce() - } - - const sourcesTab = Array.from( - container.querySelectorAll('[role="radio"]') - ).find((item) => item.textContent === 'Advanced') - expect(sourcesTab).toBeDefined() - await act(async () => sourcesTab!.click()) - expect( - container.querySelector('input[placeholder="Search sync configurations..."]') - ).toHaveValue('handbook') - await click('Accounts') - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - }) - it('preserves source navigation and retries connection availability failures', async () => { mocks.availabilityError = new Error('Connection availability could not be loaded') await render('google_drive', '?view=sources') @@ -655,6 +555,19 @@ describe('organization provider management', () => { expect(mocks.activate).not.toHaveBeenCalled() }) + it('explains unavailable integrations instead of showing an empty setup dead end', async () => { + mocks.access = { admin: false, members: false } + mocks.overview.mockReturnValue({ + data: { providers: [{ ...provider, connectorType: 'gitlab' }] }, + }) + mocks.sources.mockReturnValue({ data: [], isPending: false }) + await render('gitlab') + expect(container.textContent).toContain('Unavailable in this deployment') + expect(container.textContent).toContain('GitLab must be configured for this deployment') + expect(container.textContent).not.toContain('No GitLab projects added') + expect(container.textContent).not.toContain('Add project') + }) + it('explains and retries Slack account lookup failures without hiding its sources', async () => { const refetch = vi.fn() mocks.access = { admin: false, members: true } @@ -677,20 +590,6 @@ describe('organization provider management', () => { expect(refetch).toHaveBeenCalledOnce() }) - it('scopes account management and connection requests to the current provider option', async () => { - await render('google_drive', '?view=accounts') - expect(mocks.sources).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: false }) - ) - expect(mocks.people).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: 'org-one', - searchConnection: { optionId: 'google-option', providerName: 'Google Drive' }, - }) - ) - }) - it.each([ { type: 'google_drive', access: { admin: true, members: true }, memberParam: false }, { type: 'gmail', access: { admin: false, members: true }, memberParam: true }, @@ -702,8 +601,7 @@ describe('organization provider management', () => { data: { providers: [{ ...provider, connectorType: type }] }, }) await render(type) - await click('Advanced') - await click('Add sync configuration') + await click(memberParam ? 'Set up member accounts' : 'Connect service account') await vi.waitFor(() => { expect(mocks.updateUrl).toHaveBeenCalled() const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) @@ -740,7 +638,7 @@ describe('organization provider management', () => { slackBotCredentialId: 'slack-bot', configurationStatus: 'needs_update', }, - ])('offers Slack Accounts recovery for an active option with $name', async (option) => { + ])('offers Slack app recovery for an active option with $name', async (option) => { mocks.access = { admin: false, members: true } mocks.overview.mockReturnValue({ data: { providers: [{ ...provider, connectorType: 'slack' }] }, @@ -762,24 +660,21 @@ describe('organization provider management', () => { }, isPending: false, }) - await render('slack', '?view=accounts&credential-group-people=alex') + await render('slack') expect(mocks.people).not.toHaveBeenCalled() - expect(container.textContent).toContain('Set up the Slack app to connect accounts.') - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') + expect(container.textContent).toContain('Set up Slack app') await click('Set up Slack app') await act(async () => { await vi.waitFor(() => expect(mocks.updateUrl).toHaveBeenCalled()) }) const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) expect(query.get('connectedAccounts')).toBe('slack') - expect(query.get('view')).toBe('accounts') - expect(query.get('credential-group-people')).toBe('alex') expect(query.has('addConnector')).toBe(false) }) it.each([undefined, 'slack-bot'])( - 'uses verified configuration for account management with bot credential %s', + 'allows source setup with verified Slack configuration and bot credential %s', async (slackBotCredentialId) => { mocks.access = { admin: false, members: true } mocks.overview.mockReturnValue({ @@ -804,11 +699,8 @@ describe('organization provider management', () => { }) await render('slack', '?view=accounts') - expect(mocks.people).toHaveBeenCalledWith( - expect.objectContaining({ - searchConnection: { optionId: 'slack-option', providerName: 'Slack' }, - }) - ) + expect(container.textContent).toContain('Add channels or DMs') + expect(mocks.people).not.toHaveBeenCalled() expect(container.textContent).not.toContain('Set up the Slack app to connect accounts.') } ) diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index cbf18f2f4e0..ecf0c0f62b5 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -1,28 +1,20 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal, ChipModalError, ChipSwitch } from '@sim/emcn' +import { ChipConfirmModal, ChipModalError } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { format } from 'date-fns' import { useRouter } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' import type { SettingsAction } from '@/components/settings/settings-header' import { SettingsPanel } from '@/components/settings/settings-panel' -import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { organizationRoutes } from '@/lib/navigation/paths' -import { getServiceConfigByProviderId, getServiceConfigByServiceId } from '@/lib/oauth' -import { - canConnectPersonally, - canConnectWithDefaults, - getConnectorAccessAvailability, -} from '@/lib/sim-search/connectors' +import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels' +import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' -import { - connectedAccountsParam, - organizationProviderTabParam, -} from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' import { OrganizationSlackAccountRemoval } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-removal' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' @@ -41,12 +33,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' import { useOrganizationSearchOverview, useSearchSources } from '@/hooks/queries/kb/connectors' import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' import { useUpdateSearchIntegration } from '@/hooks/queries/search-integrations' import { useDebounce } from '@/hooks/use-debounce' -import { useOrganizationAccountPeopleSearch } from '@/hooks/use-organization-account-people-search' import { usePermissionConfig } from '@/hooks/use-permission-config' interface OrganizationProviderDetailProps { @@ -57,31 +47,21 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const { organization, viewer, searchAccess } = useOrganizationContext() const router = useRouter() const meta = CONNECTOR_META_REGISTRY[connectorType] - const automaticSetup = Boolean(meta && canConnectWithDefaults(meta) && searchAccess.memberScoped) - const [view, setView] = useQueryState( - organizationProviderTabParam.key, - organizationProviderTabParam.parser - ) const [search, setSearch] = useSettingsSearch() - const [peopleSearch, setPeopleSearch] = useOrganizationAccountPeopleSearch() const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) const [deactivating, setDeactivating] = useState(false) const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false) const scope = { kind: 'organization', organizationId: organization.id } as const - const personal = Boolean(meta && canConnectPersonally(meta) && searchAccess.memberScoped) - const showAccounts = view === 'accounts' && personal const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) const sources = useSearchSources(scope, { connectorType, search: sourceSearch, - enabled: viewer.isAdmin && !showAccounts, + enabled: viewer.isAdmin, }) const availability = usePermissionConfig() const approval = useUpdateSearchIntegration() const accounts = useOrganizationAccounts( - viewer.isAdmin && (connectorType === 'slack' || (personal && showAccounts)) - ? organization.id - : undefined + viewer.isAdmin && connectorType === 'slack' ? organization.id : undefined ) const [, setSetup] = useQueryStates( { @@ -103,19 +83,11 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid router.push(organizationRoutes(organization.id).settingsSection('integrations')), } if (!viewer.isAdmin || !meta) return null - const searchField = showAccounts - ? { value: peopleSearch, onChange: setPeopleSearch, placeholder: 'Search people...' } - : { - value: search, - onChange: setSearch, - placeholder: automaticSetup ? 'Search sync configurations...' : 'Search sources...', - } - const panel = { - back, - title: meta.name, - description: provider ? organizationSearchStatusLabel(provider) : undefined, - docsLink: meta.searchDocsUrl, - search: searchField, + const labels = getSearchConnectionLabels(connectorType) + const searchField = { + value: search, + onChange: setSearch, + placeholder: labels.searchPlaceholder, } const access = getConnectorAccessAvailability(meta, availability.integrationAvailability, { memberAccessAvailable: searchAccess.memberScoped, @@ -123,16 +95,22 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid oauthServiceAvailability: availability.oauthServiceAvailability, isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, }) - const service = - meta.auth.mode === 'oauth' - ? (getServiceConfigByServiceId(meta.auth.provider) ?? - getServiceConfigByProviderId(meta.auth.provider)) - : undefined - const credentialProvider = service - ? findCredentialGroupProviderFromProviderId(service.providerId) - : undefined + const unavailable = + availability.isIntegrationAvailabilityReady && !access.admin && !access.members + const panel = { + back, + title: meta.name, + description: + approved && unavailable + ? 'Unavailable in this deployment' + : provider + ? organizationSearchStatusLabel(provider) + : undefined, + docsLink: meta.searchDocsUrl, + search: searchField, + } const option = accounts.data?.credentialGroup?.options.find( - (item) => item.provider === credentialProvider && item.status === 'active' + (item) => item.provider === 'slack' && item.status === 'active' ) const group = accounts.data?.credentialGroup const removalActions: SettingsAction[] = @@ -141,8 +119,8 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid group?.options.some((item) => item.provider === 'slack') ? [ { - text: 'Remove account setup', - textTone: 'error', + id: 'delete', + text: 'Remove app setup', disabled: accounts.isFetching, onSelect: () => setRemovingSlackAccounts(true), }, @@ -165,14 +143,13 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid approval.mutate({ organizationId: organization.id, connectorType, approved: true }) const actions: SettingsAction[] = approved ? [ - ...((access.admin || access.members) && (!automaticSetup || !showAccounts) + ...(access.admin || access.members ? [ { text: needsSlackSetup ? 'Set up Slack app' - : automaticSetup - ? 'Add sync configuration' - : 'Add source', + : getSearchConnectionLabels(connectorType, access.admin ? 'admin' : 'members') + .add, icon: Plus, variant: 'primary' as const, disabled: @@ -196,6 +173,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid text: provider ? 'Activate' : 'Add integration', variant: 'primary', disabled: pending || (!access.admin && !access.members), + tooltip: unavailable ? 'This integration is unavailable in this deployment.' : undefined, onSelect: activate, }, ] @@ -247,13 +225,13 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {sources.isError && !sources.isFetchNextPageError ? ( void sources.refetch()} variant='inline' /> ) : sources.isPending ? ( - Loading sources… + Loading connections… ) : (
{sources.data?.map((source) => ( @@ -261,12 +239,14 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid key={source.connectorId} title={source.sourceDescription || meta.name} description={[ - source.accessMode === 'members' - ? 'Member accounts' - : meta.auth.mode === 'oauth' && - meta.auth.adminCredentialType === 'service_account' - ? 'Service account' - : 'Admin account', + connectorType === 'github' + ? null + : source.accessMode === 'members' + ? 'Member accounts' + : meta.auth.mode === 'oauth' && + meta.auth.adminCredentialType === 'service_account' + ? 'Service account' + : 'Admin or service account', !approved ? 'Deactivated' : !source.enabled @@ -282,7 +262,9 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid : source.lastSyncAt ? `Last synced ${format(new Date(source.lastSyncAt), 'MMM d, h:mm a')}` : 'Waiting for the first sync', - ].join(' · ')} + ] + .filter(Boolean) + .join(' · ')} href={organizationRoutes(organization.id).searchSource(source.connectorId)} clickLabel={`Open ${source.sourceDescription || meta.name}`} navigable @@ -291,12 +273,12 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {!sources.data?.length && !sources.hasNextPage && ( {sourceSearch - ? 'No matching sources' - : !approved - ? 'Activate this integration to set up sources.' - : automaticSetup - ? 'No sync configurations yet.' - : 'No sources yet.'} + ? 'No matching connections' + : unavailable + ? `${meta.name} must be configured for this deployment before you can add a connection.` + : !approved + ? 'Activate this integration to add a connection.' + : labels.empty} )} @@ -306,81 +288,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid ) return ( <> -
- {personal && ( -
- void setView(value)} - options={ - automaticSetup - ? [ - { value: 'accounts', label: 'Accounts' }, - { value: 'sources', label: 'Advanced' }, - ] - : [ - { value: 'sources', label: 'Sources' }, - { value: 'accounts', label: 'Accounts' }, - ] - } - /> -
- )} - {showAccounts ? ( - accounts.isError ? ( - - void accounts.refetch()} - variant='inline' - /> - - ) : accounts.isPending ? ( - - Loading accounts… - - ) : option && approved && !needsSlackSetup ? ( - - ) : ( - - {approval.error && ( - - {approval.error.message} - - )} - {availability.integrationAvailabilityError && ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - )} - - {approved - ? needsSlackSetup - ? 'Set up the Slack app to connect accounts.' - : automaticSetup - ? provider && provider.sourceCount > 0 - ? 'No connected member accounts.' - : 'Members connect their accounts from Integrations. Indexing starts automatically.' - : 'Add a source to set up account connections.' - : 'Activate this integration to set up account connections.'} - - - ) - ) : ( - renderSources() - )} -
+ {renderSources()} > diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx index fd1fc7944d9..0dbbe4341de 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx @@ -144,8 +144,8 @@ describe('organization source detail navigation', () => { disabled: options.disabled, onSelect: vi.fn(), }, - { id: 'pause', text: 'Pause', disabled: options.disabled, onSelect: vi.fn() }, - { id: 'delete', text: 'Remove', disabled: options.disabled, onSelect: vi.fn() }, + { id: 'pause', text: 'Pause syncing', disabled: options.disabled, onSelect: vi.fn() }, + { id: 'delete', text: 'Remove connection', disabled: options.disabled, onSelect: vi.fn() }, ], })) mocks.form.mockImplementation(() => ({ @@ -205,31 +205,20 @@ describe('organization source detail navigation', () => { ) }) - it.each(['members', 'admin'] as const)( - 'links member sources to personal Search connections: %s', - async (accessMode) => { - mocks.detail.mockReturnValue({ data: { ...connector, accessMode } }) - await render() - const link = container.querySelector('a[aria-label="Manage your Search accounts"]') - if (accessMode === 'members') { - expect(link).toHaveAttribute('href', '/o/org-one/integrations') - expect(container.textContent).toContain( - 'Each person connects from Integrations to sync content they can access.' - ) - } else { - expect(link).toBeNull() - } + it.each(['excluded', 'failed', 'skipped'])( + 'restores document search and %s status from the shared URL', + async (filter) => { + await render(`?search=notes&document-filter=${filter}`) + expect(mocks.documents).toHaveBeenLastCalledWith( + expect.objectContaining({ search: 'notes', filter }) + ) + expect(mocks.documents).toHaveBeenLastCalledWith( + expect.objectContaining({ + searchControl: { value: 'notes', onChange: expect.any(Function) }, + }) + ) } ) - it('restores document search and status from the shared URL', async () => { - await render('?search=notes&document-filter=excluded') - expect(mocks.documents).toHaveBeenLastCalledWith( - expect.objectContaining({ search: 'notes', filter: 'excluded' }) - ) - expect(mocks.documents).toHaveBeenLastCalledWith( - expect.objectContaining({ searchControl: { value: 'notes', onChange: expect.any(Function) } }) - ) - }) it.each(['', '?view=history'])( 'shows a concise incomplete-update notice without provider details at %s', @@ -239,8 +228,10 @@ describe('organization source detail navigation', () => { }) await render(searchParams) - expect(container.textContent).toContain('Some source updates are incomplete') - expect(container.textContent).toContain('Review the source settings and try syncing again.') + expect(container.textContent).toContain('Some connection updates are incomplete') + expect(container.textContent).toContain( + 'Review the connection settings and try syncing again.' + ) expect(container.textContent).not.toContain('Provider denied') expect(container.textContent).not.toContain('org-private-id') } @@ -248,8 +239,10 @@ describe('organization source detail navigation', () => { it('keeps a healthy active source quiet', async () => { await render() - expect(container.textContent).not.toContain('Some source updates are incomplete') - expect(container.textContent).not.toContain('Review the source settings and try syncing again.') + expect(container.textContent).not.toContain('Some connection updates are incomplete') + expect(container.textContent).not.toContain( + 'Review the connection settings and try syncing again.' + ) }) it.each(['', '?view=settings', '?view=history'])( @@ -293,7 +286,7 @@ describe('organization source detail navigation', () => { async (status) => { mocks.detail.mockReturnValue({ data: { ...connector, status, lastSyncError: 'Old failure' } }) await render() - expect(container.textContent).not.toContain('Some source updates are incomplete') + expect(container.textContent).not.toContain('Some connection updates are incomplete') } ) @@ -319,7 +312,9 @@ describe('organization source detail navigation', () => { }, }) await render() - expect(container.textContent?.includes('Some source updates are incomplete')).toBe(showNotice) + expect(container.textContent?.includes('Some connection updates are incomplete')).toBe( + showNotice + ) expect(container.textContent).not.toContain('private-account-id') } ) @@ -351,7 +346,7 @@ describe('organization source detail navigation', () => { await render() expect(mocks.actions).not.toHaveBeenCalled() expect(mocks.documents).not.toHaveBeenCalled() - expect(container.textContent).toContain('Loading source') + expect(container.textContent).toContain('Loading connection') }) it('does not load protected source data for non-admins', async () => { mocks.admin = false @@ -368,7 +363,7 @@ describe('organization source detail navigation', () => { mocks.index.mockReturnValue({ data: { knowledgeBaseId: null }, isPending: false }) mocks.detail.mockReturnValue({}) await render() - expect(container.textContent).toContain('This source is no longer available') + expect(container.textContent).toContain('This connection is no longer available') expect(mocks.actions).not.toHaveBeenCalled() await click('Sources') expect(mocks.push).toHaveBeenCalledWith('/o/org-one/settings/integrations') @@ -379,7 +374,7 @@ describe('organization source detail navigation', () => { expect(mocks.actions).toHaveBeenLastCalledWith( expect.objectContaining({ disabled: true, primarySync: false }) ) - for (const label of ['Sync now', 'Pause', 'Remove']) { + for (const label of ['Sync now', 'Pause syncing', 'Remove connection']) { const action = Array.from(container.querySelectorAll('button')).find( (item) => item.textContent === label ) @@ -402,7 +397,7 @@ describe('organization source detail navigation', () => { const sync = Array.from(container.querySelectorAll('button')).find( (item) => item.textContent === 'Sync now' ) - const tabs = container.querySelector('[aria-label="Source views"]') + const tabs = container.querySelector('[aria-label="Connection views"]') expect(sync).toBeTruthy() expect(tabs).toBeTruthy() expect(sync!.compareDocumentPosition(tabs!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) @@ -512,8 +507,8 @@ describe('organization source detail navigation', () => { await render('?view=settings') for (const label of [ 'Sync now', - 'Pause', - 'Remove', + 'Pause syncing', + 'Remove connection', 'Saving...', ...(dirty ? ['Discard'] : []), ]) { diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx index 1439c907041..939dfd52563 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx @@ -70,7 +70,7 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta if (!viewer.isAdmin) return ( - + Only organization admins can manage sources. @@ -89,17 +89,17 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta ) if (failedQuery && (!hasCanonicalDetail || accessFailure)) return ( - + {isApiClientError(failedQuery.error) && failedQuery.error.status === 404 ? ( - This source is no longer available. + This connection is no longer available. ) : ( void failedQuery.refetch()} - fallback='Could not load source' + fallback='Could not load connection' variant='inline' /> )} @@ -107,9 +107,9 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta ) if (!index.isPending && !knowledgeBaseId) return ( - + - This source is no longer available. + This connection is no longer available. ) @@ -120,8 +120,8 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta detail.data.knowledgeBaseId !== knowledgeBaseId ) return ( - - Loading source… + + Loading connection… ) return ( @@ -137,7 +137,7 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta error={failedQuery.error} isRetrying={failedQuery.isFetching} onRetry={() => void failedQuery.refetch()} - fallback='Could not refresh source' + fallback='Could not refresh connection' variant='inline' /> ) : undefined @@ -184,7 +184,7 @@ function SourceDetailContent({ const meta = CONNECTOR_META_REGISTRY[connector.connectorType] const title = meta ? describeSearchSource(meta, connector.sourceConfig) || meta.name - : 'Search source' + : 'Connection' const { effectiveStatus, lastSyncError } = getConnectorSyncState(connector) const status = effectiveStatus === 'paused' @@ -209,7 +209,7 @@ function SourceDetailContent({ return ( )} - {connector.accessMode === 'members' && ( - - )} ) if (view === 'settings') @@ -279,8 +270,8 @@ function SourceDetailContent({ {effectiveStatus === 'active' && lastSyncError && ( )}
) diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index 493ddeb8be3..e07ffe5a381 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -9,10 +9,10 @@ import { BubbleChatClose, BubbleChatPreview, Button, - ButtonGroup, - ButtonGroupItem, Checkbox, ChevronDown, + ChipButtonGroup, + ChipButtonGroupItem, ChipDatePicker, Code, Combobox, @@ -326,37 +326,36 @@ export default function PlaygroundPage() { - {/* ButtonGroup */} -
+
- - cURL - Python - JavaScript - + + cURL + Python + JavaScript + - - Option 1 - Option 2 - + + Option 1 + Option 2 + - - Option 1 - Option 2 - + + Option 1 + Option 2 + - - Option 1 - Option 2 - + + Option 1 + Option 2 + - - Only Option - + + Only Option +
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx index e188eae0f80..e3ffed7374c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx @@ -85,7 +85,7 @@ vi.mock('@sim/emcn/icons', () => ({ vi.mock('@sim/emcn', () => ({ toast: { success: mockToastSuccess }, - ButtonGroup: ({ + ChipButtonGroup: ({ children, value, onValueChange, @@ -110,7 +110,7 @@ vi.mock('@sim/emcn', () => ({ )} ), - ButtonGroupItem: ({ + ChipButtonGroupItem: ({ value, children, selectedValue, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index 298f5906f5b..5d354b61e49 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -2,9 +2,9 @@ import { useState } from 'react' import { - ButtonGroup, - ButtonGroupItem, Chip, + ChipButtonGroup, + ChipButtonGroupItem, ChipConfirmModal, ChipModal, ChipModalBody, @@ -206,18 +206,18 @@ export function ShareModal({ - setDraftMode(value as ShareAuthType)} aria-label='File access' disabled={upsertShare.isPending} > {accessModes.map((mode) => ( - + {ACCESS_LABELS[mode]} - + ))} - + {effectiveMode === 'password' ? ( Searching…

+ return ( +
+ +
+ ) } const indexingNote = diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx new file mode 100644 index 00000000000..c25f9b774e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx @@ -0,0 +1,59 @@ +'use client' + +import { type ReactNode, useId } from 'react' +import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' +import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport' + +interface ActivityDisclosureProps { + header: ReactNode + children: ReactNode + expanded: boolean + onToggle: () => void + isStreaming: boolean + unbounded?: boolean +} + +/** Shared disclosure chrome; callers own expansion and blocking-interaction decisions. */ +export function ActivityDisclosure({ + header, + children, + expanded, + onToggle, + isStreaming, + unbounded = false, +}: ActivityDisclosureProps) { + const contentId = useId() + const headerId = useId() + + return ( +
+ + + + + {children} + + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx new file mode 100644 index 00000000000..977a74557aa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx @@ -0,0 +1,96 @@ +'use client' + +import { type ReactNode, useEffect, useLayoutEffect, useRef } from 'react' +import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' + +interface ActivityViewportProps { + children: ReactNode + isStreaming: boolean + /** A nested blocking interaction must not be clipped by this ancestor's log viewport. */ + unbounded?: boolean +} + +const BOTTOM_STICK_THRESHOLD_PX = 8 + +export function ActivityViewport({ + children, + isStreaming, + unbounded = false, +}: ActivityViewportProps) { + const ref = useRef(null) + const rafRef = useRef(null) + const stickToBottomRef = useRef(true) + const prevScrollTopRef = useRef(0) + const edges = useScrollEdges(ref, { enabled: !unbounded }) + + useEffect(() => { + if (unbounded) { + stickToBottomRef.current = true + return + } + const el = ref.current + if (!el) return + /** Upward input detaches auto-stick; reaching the bottom while scrolling down resumes it. */ + const handleWheel = (e: WheelEvent) => { + if (e.deltaY < 0) stickToBottomRef.current = false + } + const handleScroll = () => { + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) { + stickToBottomRef.current = true + } + prevScrollTopRef.current = el.scrollTop + } + el.addEventListener('wheel', handleWheel, { passive: true }) + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + el.removeEventListener('wheel', handleWheel) + el.removeEventListener('scroll', handleScroll) + } + }, [unbounded]) + + useLayoutEffect(() => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + if (unbounded || !isStreaming) return + const tick = () => { + const node = ref.current + if (!node || !stickToBottomRef.current) { + rafRef.current = null + return + } + const target = node.scrollHeight - node.clientHeight + const gap = target - node.scrollTop + if (gap < 1) { + rafRef.current = null + return + } + node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18) + rafRef.current = window.requestAnimationFrame(tick) + } + rafRef.current = window.requestAnimationFrame(tick) + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } + }) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index dd7abace0d6..c9733459807 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -1,20 +1,16 @@ 'use client' -import { - type ComponentType, - type ReactNode, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react' -import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn' -import { ShimmerText } from '@/components/ui' +import { type ComponentType, type ReactNode, useMemo, useState } from 'react' +import { ActivityStatus } from '@/components/ui/activity-status' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' +import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure' +import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown' +import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' +import { getToolActivitySummary } 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' +import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' import { getAgentIcon, isToolDone, @@ -47,13 +43,11 @@ export interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean - /** This group is the latest section in its parent sequence (drives collapse). */ - isCurrentSection?: boolean /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */ isLaneOpen?: boolean - /** Opens the group on first render without changing production's automatic collapse rules. */ + /** Opens a subagent group on first render. */ defaultExpanded?: boolean - /** Keeps the activity viewport anchored at the top while new rows stream in. */ + /** Follows incoming activity until the user scrolls up. */ autoScrollActivity?: boolean } @@ -78,12 +72,11 @@ function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] { return tools } -/** True when any row in this group (or a nested one) is waiting on a permission decision. */ -function hasAwaitingApproval(items: AgentGroupItem[]): boolean { +/** Reveal blocking interactions even when a parent group was manually collapsed. */ +function hasPendingInteraction(items: AgentGroupItem[]): boolean { return items.some((item) => { - if (item.type === 'tool') return item.data.status === ToolCallStatus.awaiting_approval - // Text rows carry no tool calls, so only nested groups need recursing into. - return item.type === 'agent_group' ? hasAwaitingApproval(item.group.items) : false + if (item.type === 'tool') return needsToolInput(item.data) + return item.type === 'agent_group' ? hasPendingInteraction(item.group.items) : false }) } @@ -151,7 +144,6 @@ export function AgentGroupView({ items, isDelegating = false, isStreaming = false, - isCurrentSection = false, isLaneOpen = false, defaultExpanded = false, autoScrollActivity = true, @@ -159,15 +151,14 @@ export function AgentGroupView({ renderBrowserTakeover, }: AgentGroupViewProps) { const AgentIcon = getAgentIcon(agentName) + const agentIcon = + agentName === 'browser' ? ( + + ) : ( + + ) const isMainAgent = agentName === 'mothership' - // Collapsed status line: the latest tool call, always in its RUNNING - // phrasing — it never flips to the completed rewrite (that lives in the - // expanded log). Work delegated further down bubbles up, so a group whose - // own turn is idle still narrates what its nested agent is doing rather - // than freezing on its last own tool. With several tools running at any - // depth, the most recently started wins and the rest become "+ n"; between - // rounds the last tool's title stays frozen; a closed lane shows the bare - // name. + /** Open lanes surface their latest work, including work delegated to nested agents. */ const status = useMemo(() => { if (isMainAgent || !isLaneOpen) return undefined const tools = collectGroupTools(items) @@ -182,7 +173,12 @@ export function AgentGroupView({ const last = tools.at(-1) return last ? toolStatusTitle(last) : undefined }, [isLaneOpen, isMainAgent, items]) - const headerText = status ? `${agentLabel} — ${status}` : agentLabel + const completedTools = !isMainAgent && !isLaneOpen ? collectGroupTools(items) : [] + const headerText = status + ? `${agentLabel} — ${status}` + : completedTools.length > 0 + ? `${agentLabel} — ${getToolActivitySummary(completedTools)}` + : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -192,26 +188,14 @@ export function AgentGroupView({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // SUBAGENT groups never auto-expand: the collapsed row IS the live view — - // label plus latest running tool title. Expanding is a deliberate user - // action; only a pending permission prompt or a browser hand-back forces - // one open. The MAIN lane ("Sim") is not a delegation card: its narration - // and tool calls are the turn itself, so it keeps the original live-expand - // behavior (open while streaming/current, settles when superseded). - const autoExpanded = isMainAgent && isStreaming && (isCurrentSection || isLaneOpen || !resolved) - const [manualExpanded, setManualExpanded] = useState( - defaultExpanded ? true : null - ) + const [manualExpanded, setManualExpanded] = useState(defaultExpanded) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) - // An outstanding permission prompt overrides a manual collapse: the turn - // cannot proceed until it is answered, so hiding it would deadlock the chat - // with nothing on screen to explain why. + const pendingInteraction = hasPendingInteraction(items) + /** Blocking interactions override manual collapse so the user can resume the turn. */ const expanded = - hasAwaitingApproval(items) || + pendingInteraction || nestedBrowserTakeover || - (activeBrowserTakeover - ? expandedTakeoverId === activeBrowserTakeover.id - : (manualExpanded ?? autoExpanded)) + (activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded) const toggleExpanded = () => { if (activeBrowserTakeover) { @@ -221,94 +205,75 @@ export function AgentGroupView({ setManualExpanded(!expanded) } + const renderItem = (item: AgentGroupItem, idx: number) => { + if (item.type === 'tool') { + return ( + + ) + } + if (item.type === 'agent_group') { + return ( + + ) + } + return ( + + ) + } + + const activity = isMainAgent ? ( + + ) : ( +
{items.map(renderItem)}
+ ) + const header = + return ( -
- {hasItems ? ( - + {activity} + ) : ( -
-
- -
- {isWorking ? ( - {headerText} - ) : ( - - )} -
- )} - {hasItems && ( - - - -
- {items.map((item, idx) => { - if (item.type === 'tool') { - return ( - - ) - } - if (item.type === 'agent_group') { - return ( -
- -
- ) - } - return ( - - ) - })} -
-
-
-
+ header )} {activeBrowserTakeover && (
@@ -334,112 +299,8 @@ function NarrationText({ content, isStreaming }: NarrationTextProps) { const revealed = useSmoothText(content, isStreaming) return ( - + {renderInlineMarkdown(revealed.trim())} ) } - -interface BoundedViewportProps { - children: React.ReactNode - isStreaming: boolean - /** A nested blocking interaction must not be clipped by this ancestor's log viewport. */ - unbounded?: boolean -} - -const BOTTOM_STICK_THRESHOLD_PX = 8 - -function BoundedViewport({ children, isStreaming, unbounded = false }: BoundedViewportProps) { - const ref = useRef(null) - const rafRef = useRef(null) - const stickToBottomRef = useRef(true) - const prevScrollTopRef = useRef(0) - const [hasOverflow, setHasOverflow] = useState(false) - - useEffect(() => { - if (unbounded) { - stickToBottomRef.current = true - return - } - const el = ref.current - if (!el) return - // Upward user input detaches auto-stick; a downward scroll reaching the - // bottom re-attaches it (a small upward flick can't re-stick itself). - const handleWheel = (e: WheelEvent) => { - if (e.deltaY < 0) stickToBottomRef.current = false - } - const handleScroll = () => { - const distance = el.scrollHeight - el.scrollTop - el.clientHeight - if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) { - stickToBottomRef.current = true - } - prevScrollTopRef.current = el.scrollTop - } - el.addEventListener('wheel', handleWheel, { passive: true }) - el.addEventListener('scroll', handleScroll, { passive: true }) - return () => { - el.removeEventListener('wheel', handleWheel) - el.removeEventListener('scroll', handleScroll) - } - }, [unbounded]) - - useLayoutEffect(() => { - const el = ref.current - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - if (unbounded) { - setHasOverflow(false) - return - } - if (el) { - const next = el.scrollHeight > el.clientHeight - setHasOverflow((prev) => (prev === next ? prev : next)) - } - if (!isStreaming) return - const tick = () => { - const node = ref.current - if (!node || !stickToBottomRef.current) { - rafRef.current = null - return - } - const target = node.scrollHeight - node.clientHeight - const gap = target - node.scrollTop - if (gap < 1) { - rafRef.current = null - return - } - node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18) - rafRef.current = window.requestAnimationFrame(tick) - } - rafRef.current = window.requestAnimationFrame(tick) - return () => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }) - - return ( -
-
- {children} -
- {!unbounded && hasOverflow && ( - <> -
-
- - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 2057028ff17..8dd4c725517 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -2,22 +2,29 @@ * @vitest-environment jsdom */ import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' -import { describe, expect, it, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group' -import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' -import { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' -import type { ToolCallData, ToolCallStatus } from '../../../../types' +import { + type AgentGroupItem, + AgentGroupView, + isAgentGroupResolved, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: () => true, })) -vi.mock('../special-tags', () => ({ - CredentialDisplay: ({ data }: { data: Array<{ name?: string }> }) => data[0]?.name ?? '', - BrowserTakeoverQuestion: ({ reason, answer }: { reason?: string; answer?: string }) => - createElement('div', { 'data-takeover-answer': 'true' }, `${reason}: ${answer}`), -})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags', + () => ({ + CredentialDisplay: ({ data }: { data: Array<{ name?: string }> }) => data[0]?.name ?? '', + BrowserTakeoverQuestion: ({ reason, answer }: { reason?: string; answer?: string }) => + createElement('div', { 'data-takeover-answer': 'true' }, `${reason}: ${answer}`), + }) +) let toolSeq = 0 @@ -97,6 +104,434 @@ describe('isAgentGroupResolved', () => { }) }) +describe('AgentGroup inline main activity', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it.each([ + ['executing', 'Reading notes'], + ['success', 'Read notes'], + ['error', 'Failed reading notes'], + ['cancelled', 'Stopped reading notes'], + ['skipped', 'Skipped reading notes'], + ['rejected', 'Failed reading notes'], + ['interrupted', 'Stopped reading notes'], + ] as const)('renders a single %s tool once without a disclosure', (status, expected) => { + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items: [ + { + type: 'tool', + data: { + id: 'read', + toolName: 'read', + displayTitle: 'Reading notes', + status, + }, + }, + ], + isStreaming: status === 'executing', + }) + ) + ) + expect(container.textContent).toBe(expected) + expect(container.querySelectorAll('[role="status"]')).toHaveLength(1) + expect(container.querySelector('button')).toBeNull() + expect(container.querySelector('[data-state]')).toBeNull() + expect(Boolean(container.querySelector('[class*="shimmer"]'))).toBe(status === 'executing') + }) + + it('replaces the active status in place and expands the full completed history', () => { + const first: AgentGroupItem = { + type: 'tool', + data: { id: 'first', toolName: 'grep', displayTitle: 'Searching files', status: 'executing' }, + } + const next: AgentGroupItem = { + type: 'tool', + data: { id: 'next', toolName: 'read', displayTitle: 'Reading notes', status: 'executing' }, + } + const render = (items: AgentGroupItem[], isStreaming = true) => { + act(() => { + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming, + }) + ) + }) + } + + render([first]) + expect(container.textContent).toBe('Searching files') + expect(container.querySelector('button')).toBeNull() + const activity = container.firstElementChild + + render([first, next]) + expect(container.firstElementChild).toBe(activity) + expect(container.textContent).toBe('Reading notes') + expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() + expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('svg')).not.toBeNull() + expect(container.textContent).not.toContain('Sim') + + render( + [ + { ...first, data: { ...first.data, status: 'success' } }, + { ...next, data: { ...next.data, status: 'success' } }, + ], + false + ) + expect(container.textContent).toBe('Searched files, read files') + expect(container.querySelector('[class*="shimmer"]')).toBeNull() + const header = container.querySelector('button') + act(() => header?.click()) + expect(header?.getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'Searched filesRead notes' + ) + act(() => header?.click()) + expect(header?.getAttribute('aria-expanded')).toBe('false') + expect(container.textContent).toBe('Searched files, read files') + }) + + it('keeps history expanded as new tools arrive', () => { + const first: AgentGroupItem = { + type: 'tool', + data: { id: 'first', toolName: 'read', displayTitle: 'Reading notes', status: 'success' }, + } + const second: AgentGroupItem = { + type: 'tool', + data: { + id: 'second', + toolName: 'read', + displayTitle: 'Reading more notes', + status: 'success', + }, + } + const render = (items: AgentGroupItem[]) => + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming: true, + }) + ) + ) + render([first, second]) + act(() => container.querySelector('button')?.click()) + render([ + first, + second, + { + type: 'tool', + data: { + id: 'third', + toolName: 'terminal_run', + displayTitle: 'Running checks', + status: 'executing', + }, + }, + ]) + expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'Read notesRead more notesRunning checks' + ) + }) + + it('shares one countdown and preserves the viewport across active tool changes', () => { + vi.useFakeTimers() + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval') + try { + const wait: AgentGroupItem = { + type: 'tool', + data: { + id: 'wait-first', + toolName: 'wait', + displayTitle: 'Waiting', + status: 'executing', + params: { seconds: 3 }, + }, + } + const read: AgentGroupItem = { + type: 'tool', + data: { id: 'read', toolName: 'read', displayTitle: 'Reading notes', status: 'success' }, + } + const render = (items: AgentGroupItem[]) => + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming: true, + }) + ) + ) + render([wait]) + act(() => vi.advanceTimersByTime(2000)) + expect(container.textContent).toBe('Waiting 1s') + expect(container.querySelector('button')).toBeNull() + render([wait, read]) + expect(container.textContent).toBe('Waiting 1s') + expect(setIntervalSpy).toHaveBeenCalledTimes(1) + const header = container.querySelector('button') + act(() => header?.click()) + expect(header?.hasAttribute('aria-label')).toBe(false) + expect(header?.textContent).toBe('Waiting 1s') + expect(header).toHaveAccessibleName('Waiting 1s') + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'Waiting 1sRead notes' + ) + expect(setIntervalSpy).toHaveBeenCalledTimes(1) + act(() => header?.click()) + act(() => header?.click()) + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'Waiting 1sRead notes' + ) + const viewport = container.querySelector('.overflow-y-auto') + render([ + { ...wait, data: { ...wait.data, status: 'success' } }, + read, + { ...wait, data: { ...wait.data, id: 'wait-second' } }, + ]) + expect(header?.textContent).toBe('Waiting 3s') + expect(header).toHaveAccessibleName('Waiting 3s') + expect(container.querySelector('.overflow-y-auto')).toBe(viewport) + expect(container.querySelector('[data-state="open"]')?.textContent).toBe( + 'WaitedRead notesWaiting 3s' + ) + expect(setIntervalSpy).toHaveBeenCalledTimes(2) + render([ + { ...wait, data: { ...wait.data, status: 'success' } }, + read, + { ...wait, data: { ...wait.data, id: 'wait-second', status: 'success' } }, + ]) + expect(header?.textContent).toBe('Waited, read files') + expect(container.querySelector('.overflow-y-auto')).toBe(viewport) + expect(clearIntervalSpy).toHaveBeenCalledTimes(2) + } finally { + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it.each(['browser', 'workflow', 'research', 'deploy', 'file', 'table'])( + 'summarizes and expands the full %s activity history', + (agentName) => { + const items: AgentGroupItem[] = [ + { + type: 'tool', + data: { id: 'read', toolName: 'read', displayTitle: 'Reading notes', status: 'success' }, + }, + { + type: 'tool', + data: { + id: 'run', + toolName: 'terminal', + displayTitle: 'Running checks', + status: 'success', + params: { operation: 'run' }, + }, + }, + ] + act(() => + root.render( + createElement(AgentGroupView, { + agentName, + agentLabel: 'Agent', + items, + ToolCallComponent: ({ toolCallId, displayTitle }: ToolCallItemProps) => + createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle), + }) + ) + ) + const header = container.querySelector('button') + expect(header?.textContent).toBe('Agent — Read files, ran commands') + expect(header).toHaveAccessibleName('Agent — Read files, ran commands') + expect(container.querySelectorAll('[data-tool-call-id]')).toHaveLength(0) + act(() => header?.click()) + expect( + Array.from(container.querySelectorAll('[data-tool-call-id]'), (row) => + row.getAttribute('data-tool-call-id') + ) + ).toEqual(['read', 'run']) + act(() => header?.click()) + expect(header?.getAttribute('aria-expanded')).toBe('false') + } + ) + + it('reveals a nested terminal handoff through collapsed ancestors', () => { + act(() => + root.render( + createElement(AgentGroupView, { + agentName: 'workflow', + agentLabel: 'Workflow', + isLaneOpen: true, + isStreaming: true, + items: [ + group([ + { + type: 'tool', + data: { + id: 'handoff', + toolName: 'terminal', + displayTitle: 'Finish signing in', + status: 'executing', + params: { operation: 'handoff' }, + }, + }, + ]), + ], + ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => { + const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle) + return renderStatus ? renderStatus(status) : status + }, + }) + ) + ) + const headers = Array.from(container.querySelectorAll('button')) + expect(headers).toHaveLength(2) + expect(headers.every((header) => header.getAttribute('aria-expanded') === 'true')).toBe(true) + act(() => headers[0].click()) + expect(headers[0].getAttribute('aria-expanded')).toBe('true') + expect( + container.querySelector('[data-tool-call-id="handoff"]')?.closest('[data-state="closed"]') + ).toBeNull() + }) + + it('keeps a browser question and answer after the main agent resumes tool activity', () => { + const takeover = browserTakeover('Choose a result.') + const items: AgentGroupItem[] = [ + { + ...takeover, + data: { + ...takeover.data, + status: 'success', + result: { success: true, output: { userInstruction: 'Open the second result.' } }, + }, + }, + { + type: 'tool', + data: { + id: 'resumed', + toolName: 'grep', + displayTitle: 'Searching files', + status: 'success', + }, + }, + ] + + act(() => { + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming: false, + }) + ) + }) + + expect(container.querySelector('[data-takeover-answer="true"]')?.textContent).toBe( + 'Choose a result.: Open the second result.' + ) + expect(container.textContent).toContain('Searched files') + expect( + container.querySelector('[data-takeover-answer="true"]')?.closest('[data-state]') + ).toBeNull() + }) + + it('keeps pending permissions and terminal handoffs visible when newer tools arrive', () => { + const items: AgentGroupItem[] = [ + { + type: 'tool', + data: { + id: 'permission', + toolName: 'grep', + displayTitle: 'Allow search', + status: 'awaiting_approval', + }, + }, + { + type: 'tool', + data: { + id: 'handoff', + toolName: 'terminal', + displayTitle: 'Finish signing in', + status: 'executing', + params: { operation: 'handoff' }, + }, + }, + { + type: 'tool', + data: { + id: 'previous', + toolName: 'grep', + displayTitle: 'Searching files', + status: 'success', + }, + }, + { + type: 'tool', + data: { + id: 'latest', + toolName: 'read', + displayTitle: 'Reading notes', + status: 'executing', + }, + }, + ] + act(() => { + root.render( + createElement(AgentGroupView, { + agentName: 'mothership', + agentLabel: 'Sim', + items, + isStreaming: true, + ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => { + const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle) + return renderStatus ? renderStatus(status) : status + }, + }) + ) + }) + + expect( + Array.from(container.querySelectorAll('[data-tool-call-id]'), (row) => + row.getAttribute('data-tool-call-id') + ) + ).toEqual(['permission', 'handoff', 'latest']) + expect( + container.querySelector('[data-tool-call-id="permission"]')?.closest('[data-state]') + ).toBeNull() + expect( + container.querySelector('[data-tool-call-id="handoff"]')?.closest('[data-state]') + ).toBeNull() + }) +}) + describe('AgentGroup browser takeover', () => { it('collapses the browser log and renders the question outside its viewport', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -111,7 +546,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [tool('success'), browserTakeover(reason)], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -185,7 +619,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [takeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -207,7 +640,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [completedTakeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx new file mode 100644 index 00000000000..70b38e2bf03 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx @@ -0,0 +1,298 @@ +/** + * @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 type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { + BrowserAgentIcon, + getBrowserAgentFaviconUrl, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const { browserAvailable, chatIdentity } = vi.hoisted(() => ({ + browserAvailable: vi.fn(() => true), + chatIdentity: { chatId: 'chat-1' }, +})) +vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: browserAvailable })) +vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({ + useChatSurface: () => chatIdentity, +})) + +function openPage(url: string, scopeId = 'chat-1', loading = false) { + act(() => + useBrowserSessionStore.getState().setTabsState({ + scopeId, + activeTabId: 'tab-1', + automationTabId: 'tab-1', + tabs: [{ tabId: 'tab-1', title: '', url, loading, active: true }], + }) + ) +} + +function tool(overrides: Partial = {}): AgentGroupItem { + return { + type: 'tool', + data: { + id: 'browser-tool', + toolName: 'browser_navigate', + displayTitle: 'Opening page', + status: 'success', + result: { success: true, output: { url: 'https://example.com/document' } }, + ...overrides, + }, + } +} + +describe('getBrowserAgentFaviconUrl', () => { + it('keeps the observed origin and port without disclosing page details', () => { + expect( + getBrowserAgentFaviconUrl([ + tool({ + result: { + success: true, + output: { + url: 'https://username:password@example.com:8443/document?token=private#part', + }, + }, + }), + ]) + ).toBe('https://example.com:8443/favicon.ico') + }) + + it('uses the pending destination, then the observed redirect, and retains it during editing', () => { + const navigating = tool({ + status: 'executing', + params: { url: 'https://example.org/start' }, + result: undefined, + }) + expect(getBrowserAgentFaviconUrl([tool(), navigating])).toBe('https://example.org/favicon.ico') + const redirected = tool({ params: { url: 'https://example.org/start' } }) + expect(getBrowserAgentFaviconUrl([redirected])).toBe('https://example.com/favicon.ico') + expect( + getBrowserAgentFaviconUrl([ + redirected, + tool({ toolName: 'browser_type', status: 'executing', result: undefined }), + ]) + ).toBe('https://example.com/favicon.ico') + }) + + it.each(['error', 'cancelled', 'rejected', 'awaiting_approval'] as const)( + 'does not adopt a destination from a %s tool', + (status) => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ status, params: { url: 'https://example.org' }, result: undefined }), + ]) + ).toBe('https://example.com/favicon.ico') + } + ) + + it.each([ + '', + 'about:blank', + 'http://example.com', + 'file:///document', + 'data:text/html,hello', + 'not a URL', + ])('clears the previous site when the page cannot supply an allowed favicon: %s', (url) => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ toolName: 'browser_switch_tab', result: { success: true, output: { url } } }), + ]) + ).toBeNull() + }) + + it.each(['browser_open_tab', 'browser_switch_tab', 'browser_close_tab', 'browser_go_back'])( + 'clears an obsolete site while %s has no known destination', + (toolName) => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ toolName, status: 'executing', result: undefined }), + ]) + ).toBeNull() + } + ) + + it('uses the agent tab from a tab list, including legacy results', () => { + const tabs = [ + { tabId: 'visible', url: 'https://example.org' }, + { tabId: 'agent', url: 'https://example.com' }, + ] + for (const output of [ + { tabs, activeTabId: 'visible', automationTabId: 'agent' }, + { tabs, activeTabId: 'agent' }, + ]) { + expect( + getBrowserAgentFaviconUrl([ + tool({ toolName: 'browser_list_tabs', result: { success: true, output } }), + ]) + ).toBe('https://example.com/favicon.ico') + } + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ + toolName: 'browser_list_tabs', + result: { + success: true, + output: { tabs, activeTabId: 'visible', automationTabId: null }, + }, + }), + ]) + ).toBeNull() + }) + + it('follows a click into a new tab and screenshot page metadata', () => { + for (const [toolName, output] of [ + [ + 'browser_click', + { activeTab: { url: 'https://example.org' }, effect: { tabChanged: true } }, + ], + ['browser_screenshot', { viewport: { url: 'https://example.org' } }], + ['browser_extract', { page: { url: 'https://example.org' } }], + ] as const) { + expect( + getBrowserAgentFaviconUrl([tool(), tool({ toolName, result: { success: true, output } })]) + ).toBe('https://example.org/favicon.ico') + } + }) + + it('clears a stale site when an action navigated without reporting its destination', () => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ + toolName: 'browser_click', + result: { success: true, output: { effect: { urlChanged: true } } }, + }), + ]) + ).toBeNull() + }) + + it('ignores URLs from other tools and nested agent runs', () => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ + toolName: 'browser_read_text', + params: { elementId: 4 }, + result: { success: true, output: { url: 'https://example.org/embedded' } }, + }), + tool({ + toolName: 'web_search', + result: { success: true, output: { url: 'https://example.org' } }, + }), + { + type: 'agent_group', + group: { + id: 'other-run', + agentName: 'browser', + agentLabel: 'Browser', + isOpen: true, + isDelegating: true, + items: [tool({ result: { success: true, output: { url: 'https://example.org' } } })], + }, + }, + ]) + ).toBe('https://example.com/favicon.ico') + }) +}) + +describe('BrowserAgentIcon', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + browserAvailable.mockReturnValue(true) + chatIdentity.chatId = 'chat-1' + useBrowserSessionStore.setState({ sessions: {}, activeScopeId: null }) + container = document.createElement('div') + root = createRoot(container) + }) + + afterEach(() => act(() => root.unmount())) + + const render = (url: string) => { + act(() => + root.render( + + ) + ) + } + + it('does not contact sites from history, other chats, or pending navigation', () => { + render('https://example.com/document') + expect(container.querySelector('img')).toBeNull() + openPage('https://example.com', 'another-chat') + expect(container.querySelector('img')).toBeNull() + openPage('https://example.com', 'chat-1', true) + expect(container.querySelector('img')).toBeNull() + openPage('https://example.org') + expect(container.querySelector('img')).toBeNull() + openPage('https://example.com') + expect(container.querySelector('img')).not.toBeNull() + }) + + it('requires the local desktop browser and retains an already loaded favicon after closing it', () => { + browserAvailable.mockReturnValue(false) + openPage('https://example.com') + render('https://example.com') + expect(container.querySelector('img')).toBeNull() + browserAvailable.mockReturnValue(true) + render('https://example.com') + const img = container.querySelector('img')! + act(() => img.dispatchEvent(new Event('load'))) + act(() => useBrowserSessionStore.getState().discardScope('chat-1')) + expect(container.querySelector('img')).toBe(img) + expect(container.querySelector('svg')).toBeNull() + }) + + it('does not carry loaded state into another chat with the same destination', () => { + openPage('https://example.com') + render('https://example.com') + act(() => container.querySelector('img')!.dispatchEvent(new Event('load'))) + chatIdentity.chatId = 'chat-2' + render('https://example.com') + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('keeps the globe until load and resets image state when the page origin changes', () => { + openPage('https://example.com/document') + render('https://username:password@example.com/document?token=private#section') + const firstImage = container.querySelector('img')! + expect(firstImage.src).toBe('https://example.com/favicon.ico') + expect(firstImage.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(container.querySelector('svg')).not.toBeNull() + act(() => firstImage.dispatchEvent(new Event('load'))) + expect(container.querySelector('svg')).toBeNull() + + render('https://example.com/another-document') + expect(container.querySelector('img')).toBe(firstImage) + expect(container.querySelector('svg')).toBeNull() + + openPage('https://example.org/document') + render('https://example.org/document') + expect(container.querySelector('img')).not.toBe(firstImage) + expect(container.querySelector('svg')).not.toBeNull() + act(() => firstImage.dispatchEvent(new Event('error'))) + expect(container.querySelector('img')).not.toBeNull() + act(() => container.querySelector('img')!.dispatchEvent(new Event('error'))) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + + openPage('https://example.com') + render('https://example.com') + expect(container.querySelector('img')).not.toBeNull() + render('about:blank') + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx new file mode 100644 index 00000000000..a3811b418cd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState } from 'react' +import { isBrowserToolName } from '@sim/browser-protocol' +import { cn } from '@sim/emcn' +import { Globe } from '@sim/emcn/icons' +import { isRecordLike } from '@sim/utils/object' +import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' +import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +function pageFaviconUrl(url: string): string | null { + try { + const page = new URL(url) + /** External images must use HTTPS under the app's content security policy. */ + if (page.protocol !== 'https:') return null + return `${page.origin}/favicon.ico` + } catch { + return null + } +} + +/** Uses this run's page observations so historical icons never follow another run's live tab. */ +export function getBrowserAgentFaviconUrl(items: AgentGroupItem[]): string | null { + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index] + if (item.type !== 'tool' || !isBrowserToolName(item.data.toolName)) continue + const { toolName, status, params, result } = item.data + if (status !== ToolCallStatus.executing && status !== ToolCallStatus.success) continue + /** Element-scoped reads can report an iframe URL rather than the browser page. */ + if (toolName === 'browser_read_text' && params?.elementId !== undefined) continue + + const navigatesToUrl = + toolName === 'browser_navigate' || + toolName === 'browser_open_url' || + toolName === 'browser_open_tab' + if (status === ToolCallStatus.executing && navigatesToUrl) { + return typeof params?.url === 'string' ? pageFaviconUrl(params.url) : null + } + + const output = result?.success && isRecordLike(result.output) ? result.output : null + if (output) { + if (isRecordLike(output.activeTab) && typeof output.activeTab.url === 'string') { + return pageFaviconUrl(output.activeTab.url) + } + if (typeof output.url === 'string') return pageFaviconUrl(output.url) + if ( + toolName === 'browser_extract' && + isRecordLike(output.page) && + typeof output.page.url === 'string' + ) { + return pageFaviconUrl(output.page.url) + } + if ( + toolName === 'browser_screenshot' && + isRecordLike(output.viewport) && + typeof output.viewport.url === 'string' + ) { + return pageFaviconUrl(output.viewport.url) + } + if (toolName === 'browser_list_tabs' && Array.isArray(output.tabs)) { + const tabId = 'automationTabId' in output ? output.automationTabId : output.activeTabId + const tab = output.tabs.find((tab) => isRecordLike(tab) && tab.tabId === tabId) + return isRecordLike(tab) && typeof tab.url === 'string' ? pageFaviconUrl(tab.url) : null + } + /** A navigation without a destination invalidates the previous page observation. */ + if ( + isRecordLike(output.effect) && + (output.effect.urlChanged === true || + output.effect.topUrlChanged === true || + output.effect.tabChanged === true) + ) { + return null + } + } + if ( + navigatesToUrl || + toolName === 'browser_switch_tab' || + toolName === 'browser_close_tab' || + toolName === 'browser_go_back' || + toolName === 'browser_go_forward' + ) { + return null + } + } + return null +} + +interface BrowserAgentIconProps { + items: AgentGroupItem[] +} + +export function BrowserAgentIcon({ items }: BrowserAgentIconProps) { + const { chatId } = useChatSurface() + const url = getBrowserAgentFaviconUrl(items) + const pageIsOpen = useBrowserSessionStore((state) => { + const session = chatId ? state.sessions[chatId] : undefined + if (!url || !session?.sessionAlive || session.suspended) return false + const tab = session.tabs.find((tab) => tab.tabId === session.automationTabId) + return Boolean(tab && !tab.loading && !tab.issue && pageFaviconUrl(tab.url) === url) + }) + return ( + + ) +} + +interface BrowserAgentFaviconProps { + url: string | null + canLoad: boolean +} + +function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) { + const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading') + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx new file mode 100644 index 00000000000..82e62b4e3a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx @@ -0,0 +1,69 @@ +import { type ComponentType, Fragment, type ReactNode } from 'react' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' +import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +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' +import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' + +interface MainAgentActivityProps { + items: AgentGroupItem[] + ToolCallComponent: ComponentType + renderItem: (item: AgentGroupItem, index: number) => ReactNode + autoScrollActivity: boolean +} + +/** Keep answers and interactions in the transcript, outside collapsible tool history. */ +function isStandaloneItem(item: AgentGroupItem): boolean { + return ( + item.type !== 'tool' || + needsToolInput(item.data) || + item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID + ) +} + +export function MainAgentActivity({ + items, + ToolCallComponent, + renderItem, + autoScrollActivity, +}: MainAgentActivityProps) { + const activity: ReactNode[] = [] + let tools: ToolCallData[] = [] + const flushTools = () => { + if (tools.length === 0) return + activity.push( + + ) + tools = [] + } + + for (const [index, item] of items.entries()) { + if (item.type === 'tool' && !isStandaloneItem(item)) { + tools.push(item.data) + continue + } + flushTools() + activity.push( + + {renderItem(item, index)} + + ) + } + flushTools() + + return
{activity}
+} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts new file mode 100644 index 00000000000..e0b17ababbf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getToolActivitySummary } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +function tool(toolName: string, status: ToolCallStatus = 'success'): ToolCallData { + return { id: toolName, toolName, displayTitle: `Running ${toolName}`, status } +} + +describe('getToolActivitySummary', () => { + it('caps distinct actions in order and counts the remaining categories, not repeated calls', () => { + expect( + getToolActivitySummary([tool('read'), tool('terminal_run'), tool('read'), tool('grep')]) + ).toBe('Read files, ran commands +1 more') + }) + + it('summarizes browser navigation and interactions without repeating actions', () => { + expect( + getToolActivitySummary([ + tool('browser_navigate'), + tool('browser_read_text'), + tool('browser_type'), + tool('browser_navigate'), + ]) + ).toBe('Navigated pages, read pages +1 more') + }) + + it('does not describe unsuccessful work as completed actions', () => { + expect( + getToolActivitySummary([ + tool('read'), + tool('apply_file_edit', 'error'), + tool('terminal_run', 'cancelled'), + tool('browser_type', 'rejected'), + ]) + ).toBe('Read files · 2 failed · 1 stopped') + }) + + it('does not invent actions when all calls failed or were stopped', () => { + expect( + getToolActivitySummary([ + tool('apply_file_edit', 'error'), + tool('terminal_run', 'interrupted'), + ]) + ).toBe('Tool activity · 1 failed · 1 stopped') + }) + + it('keeps an individual tool’s descriptive title', () => { + expect( + getToolActivitySummary([{ ...tool('read'), displayTitle: 'Reading project notes' }]) + ).toBe('Read project notes') + }) + + it.each([ + ['rejected', 'Failed running checks'], + ['skipped', 'Skipped running checks'], + ['interrupted', 'Stopped running checks'], + ] as const)('labels a single %s tool as finished', (status, expected) => { + expect( + getToolActivitySummary([{ ...tool('terminal', status), displayTitle: 'Running checks' }]) + ).toBe(expected) + }) + + it('keeps unknown tools visible with a neutral summary', () => { + expect(getToolActivitySummary([tool('future_tool'), tool('browser_future_action')])).toBe( + 'Used tools, used the browser' + ) + }) + + it('describes current browser and workflow tools', () => { + expect( + getToolActivitySummary([ + tool('browser_open_url'), + tool('browser_fill_form'), + tool('browser_insert_text'), + tool('read_document'), + tool('run_workflow'), + tool('deploy_as_api'), + tool('table_rows'), + ]) + ).toBe('Navigated pages, filled forms +5 more') + }) + + it('keeps failure and interruption counts visible when action categories are capped', () => { + expect( + getToolActivitySummary([ + tool('read'), + tool('grep'), + tool('terminal'), + tool('browser_navigate'), + tool('apply_file_edit', 'error'), + tool('wait', 'interrupted'), + tool('browser_type', 'skipped'), + ]) + ).toBe('Read files, searched files +2 more · 1 failed · 1 stopped · 1 skipped') + }) + + it('uses the same outcome wording for rejected individual and grouped calls', () => { + const rejected = { ...tool('terminal', 'rejected'), displayTitle: 'Running checks' } + expect(getToolActivitySummary([rejected])).toBe('Failed running checks') + expect(getToolActivitySummary([rejected, tool('read', 'skipped')])).toBe( + 'Tool activity · 1 failed · 1 skipped' + ) + }) + + it('deduplicates related tools and preserves opposite operations in the summary', () => { + expect( + getToolActivitySummary([ + { ...tool('deploy_as_api'), params: { action: 'deploy' } }, + { ...tool('deploy_as_chat'), params: { action: 'deploy' } }, + { ...tool('deploy_as_mcp'), params: { action: 'undeploy' } }, + tool('read'), + ]) + ).toBe('Deployed workflows, undeployed workflows +1 more') + }) + + it('describes terminal runs from their operation', () => { + expect( + getToolActivitySummary([{ ...tool('terminal'), params: { operation: 'run' } }, tool('read')]) + ).toBe('Ran commands, read files') + }) +}) 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 new file mode 100644 index 00000000000..d426fe587da --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx @@ -0,0 +1,108 @@ +'use client' + +import { type ComponentType, Fragment, useState } from 'react' +import { ActivityStatus } from '@/components/ui/activity-status' +import { getToolActivityLabel } from '@/lib/copilot/tools/tool-activity' +import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure' +import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' +import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +const MAX_SUMMARY_ACTIONS = 2 + +/** Summarize completed actions without describing failed or skipped work as successful. */ +export function getToolActivitySummary(tools: ToolCallData[]): string { + if (tools.length === 1) { + const tool = tools[0] + return getToolStatusDisplayTitle(tool.displayTitle, tool.status, tool.toolName) + } + const labels = new Set() + let failed = 0 + let stopped = 0 + let skipped = 0 + for (const tool of tools) { + if (tool.status === ToolCallStatus.success) { + labels.add(getToolActivityLabel(tool.toolName, tool.params)) + } else if (tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected) + failed++ + else if (tool.status === ToolCallStatus.cancelled || tool.status === ToolCallStatus.interrupted) + stopped++ + else if (tool.status === ToolCallStatus.skipped) skipped++ + } + const summary = Array.from(labels).slice(0, MAX_SUMMARY_ACTIONS).join(', ') + const summaryLabel = summary ? summary[0].toUpperCase() + summary.slice(1) : 'Tool activity' + const additionalActions = Math.max(0, labels.size - MAX_SUMMARY_ACTIONS) + const outcomes = [ + failed && `${failed} failed`, + stopped && `${stopped} stopped`, + skipped && `${skipped} skipped`, + ].filter(Boolean) + return [ + additionalActions > 0 ? `${summaryLabel} +${additionalActions} more` : summaryLabel, + ...outcomes, + ].join(' · ') +} + +interface ToolActivityGroupProps { + tools: ToolCallData[] + ToolCallComponent: ComponentType + autoScrollActivity?: boolean +} + +export function ToolActivityGroup({ + tools, + ToolCallComponent, + autoScrollActivity = true, +}: ToolActivityGroupProps) { + const [expanded, setExpanded] = useState(false) + let activeTool: ToolCallData | undefined + for (let index = tools.length - 1; index >= 0; index--) { + if (tools[index].status === ToolCallStatus.executing) { + activeTool = tools[index] + break + } + } + const statusTool = activeTool ?? tools[tools.length - 1] + const SummaryIcon = getToolIcon(tools[0].toolName) + + return ( + { + if (tools.length === 1) return status + return ( + } + /> + ) + } + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + isStreaming={Boolean(activeTool) && autoScrollActivity} + > +
+ {tools.map((tool) => ( + + {tool.id === statusTool.id ? ( + status + ) : ( + + )} + + ))} +
+
+ ) + }} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx index 065448ac4e7..b52d7b1b0e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -20,7 +20,7 @@ describe('ToolCallItem', () => { }) it.each(['executing', 'success', 'error', 'cancelled'] as const)( - 'renders the %s tool row without an icon', + 'renders the %s tool row with its EMCN icon', (status) => { const markup = renderToStaticMarkup( { /> ) - expect(markup).not.toContain(' ReactNode } function stringParam(params: Record | undefined, key: string): string { @@ -82,26 +90,27 @@ const COUNTDOWN_TICK_MS = 250 * mid-countdown instead of restarting; falls back to activation time when the * caller has no start to give. */ -function useElapsedMs(active: boolean, startedAt: number | undefined): number { - const [elapsedMs, setElapsedMs] = useState(0) +function useElapsedMs( + active: boolean, + startedAt: number | undefined, + toolCallId: string | undefined +): number { + const [sample, setSample] = useState({ toolCallId, elapsedMs: 0 }) useEffect(() => { - if (!active) { - setElapsedMs(0) - return - } + if (!active) return const anchor = startedAt ?? Date.now() - const tick = () => setElapsedMs(Date.now() - anchor) + const tick = () => setSample({ toolCallId, elapsedMs: Date.now() - anchor }) tick() const interval = setInterval(tick, COUNTDOWN_TICK_MS) return () => clearInterval(interval) - }, [active, startedAt]) + }, [active, startedAt, toolCallId]) - return elapsedMs + return active && sample.toolCallId === toolCallId ? sample.elapsedMs : 0 } /** - * A single tool-call row inside an agent group: shimmer while executing, a + * Inline tool activity: shimmer while executing, a * static label once terminal. For `workspace_file` the title is derived live * from the streaming args; because that path bypasses the completed-title * rewrite in `toToolData`, the past-tense flip is applied here on success. @@ -122,6 +131,7 @@ export function ToolCallItem({ streamingArgs, toolCallId, startedAt, + renderStatus, }: ToolCallItemProps) { useCustomBlockOverlayVersion() const readPath = params?.path @@ -173,7 +183,7 @@ export function ToolCallItem({ const isBrowserTakeover = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID const isCountingDown = toolName === WaitTool.id && isExecuting - const elapsedMs = useElapsedMs(isCountingDown, startedAt) + const elapsedMs = useElapsedMs(isCountingDown, startedAt, toolCallId) const liveTitle = isCountingDown ? getWaitCountdownTitle(params, elapsedMs) @@ -192,19 +202,16 @@ export function ToolCallItem({ : null const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon + const ToolIcon = getToolIcon(toolName) - // A gated row is replaced outright by its permission card, the same way an - // executing browser takeover swaps itself for the takeover chip. if (displayState === 'awaiting_approval' && toolCallId) { return ( -
- -
+ ) } @@ -212,36 +219,39 @@ export function ToolCallItem({ if (isBrowserTakeover && status === 'success') { return ( -
- -
+ ) } if (terminalHandoff) { return ( -
- -
+ ) } - return ( - } + const activity = ( + + ) : ( + + ) + } /> ) + return renderStatus ? renderStatus(activity) : activity } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row.tsx deleted file mode 100644 index fdb4af0026d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ReactNode } from 'react' -import { ShimmerText } from '@/components/ui' - -interface ToolCallRowProps { - title: string - isExecuting: boolean - icon?: ReactNode -} - -/** Shared activity chrome; callers resolve tool semantics and brand icons. */ -export function ToolCallRow({ title, isExecuting, icon }: ToolCallRowProps) { - return ( -
- {icon} - {isExecuting ? ( - - {title} - - ) : ( - - {title} - - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts new file mode 100644 index 00000000000..245c0f4a112 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts @@ -0,0 +1,12 @@ +import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +/** A permission decision or terminal handoff must stay reachable while an agent is waiting. */ +export function needsToolInput(tool: ToolCallData): boolean { + return ( + tool.status === ToolCallStatus.awaiting_approval || + (tool.status === ToolCallStatus.executing && + tool.toolName === TerminalTool.id && + tool.params?.operation === 'handoff') + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx index e1df655f9ce..5428ce8551d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx @@ -53,7 +53,8 @@ function SearchIntegrationConnectionControl({ }) const name = connector?.meta.name ?? target.provider const action = target.credentialId ? 'Reconnect' : 'Connect' - const label = connection.connected + const connected = connection.connected + const label = connected ? `Connected ${name}` : connection.isLoading ? `Checking ${name} connections…` @@ -76,18 +77,14 @@ function SearchIntegrationConnectionControl({ !connector || connection.isLoading || connection.isStarting || - connection.connected || + connected || (!connection.available && !connection.pending) } onClick={handleConnect} leading={ connector && } - trailing={ - connection.connected ? ( - - ) : undefined - } + trailing={connected ? : undefined} /> {connection.pending && Cancel} {connection.error && ( @@ -100,6 +97,8 @@ function SearchIntegrationConnectionControl({ )} {setupOpen && connector && ( setSetupOpen(false)} isPending={connection.isStarting} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 76b2976c67c..c89261507ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -335,7 +335,7 @@ describe('parseBlocks span-identity tree', () => { expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true) }) - it('interleaves mothership tools with main text instead of clustering them at the top', () => { + it('retains main activity around prose and subagents in stream order', () => { const blocks: ContentBlock[] = [ mainText('Let me search.'), mainToolCall('t1', 'grep'), @@ -348,25 +348,20 @@ describe('parseBlocks span-identity tree', () => { const segments = parseBlocks(blocks) - // Order is preserved chronologically: the second mothership tool stays below - // the research subagent and the trailing text rather than jumping back up - // into the first group. const shape = segments.map((s) => (s.type === 'agent_group' ? s.agentName : s.type)) expect(shape).toEqual(['text', 'mothership', 'research', 'text', 'mothership']) - // The two mothership tools land in two distinct groups, one each. const mothershipGroups = segments.filter( (s) => s.type === 'agent_group' && s.agentName === 'mothership' ) expect(mothershipGroups).toHaveLength(2) - const [first, second] = mothershipGroups - if (first.type !== 'agent_group' || second.type !== 'agent_group') { - throw new Error('expected mothership groups') - } - expect(first.items).toHaveLength(1) - expect(second.items).toHaveLength(1) - expect(first.items[0].type === 'tool' && first.items[0].data.toolName).toBe('grep') - expect(second.items[0].type === 'tool' && second.items[0].data.toolName).toBe('glob') + expect( + mothershipGroups.flatMap((group) => + group.type === 'agent_group' + ? group.items.flatMap((item) => (item.type === 'tool' ? [item.data.id] : [])) + : [] + ) + ).toEqual(['t1', 't2']) }) it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => { @@ -721,7 +716,7 @@ describe('narration text seams', () => { }) describe('parseBlocks legacy — thinking between top-level tools', () => { - it('keeps consecutive mothership tools in one group across intervening thinking', () => { + it('retains every main tool across intervening thinking', () => { const blocks: ContentBlock[] = [ { type: 'thinking', content: 'planning the search', timestamp: 1 }, mainToolCall('t1', 'grep'), @@ -734,10 +729,14 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { expect(groups).toHaveLength(1) if (groups[0].type !== 'agent_group') throw new Error('expected group') expect(groups[0].agentName).toBe('mothership') - expect(groups[0].items).toHaveLength(3) + expect(groups[0].items.map((item) => item.type === 'tool' && item.data.id)).toEqual([ + 't1', + 't2', + 't3', + ]) }) - it('still splits the mothership run on real main text', () => { + it('keeps separate activity groups around assistant prose', () => { const blocks: ContentBlock[] = [ mainToolCall('t1', 'grep'), mainText('Here is what I found so far.'), @@ -746,6 +745,7 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { const segments = parseBlocks(blocks) const groups = segments.filter((s) => s.type === 'agent_group') expect(groups).toHaveLength(2) + expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) }) it('does not let main thinking affect subagent lane grouping', () => { @@ -787,10 +787,28 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { }) describe('assistantMessageHasVisibleExecutingTool', () => { + it.each([undefined, 'main'])('retains an earlier running tool with spanId=%s', (spanId) => { + const blocks: ContentBlock[] = [ + { + type: 'tool_call', + toolCall: { id: 'older', name: 'grep', status: 'executing' }, + spanId, + timestamp: 1, + }, + mainText('Reading the result.'), + mainToolCall('latest', 'read'), + ] + const segments = parseBlocks(blocks) + expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) + expect(assistantMessageHasVisibleExecutingTool(segments)).toBe(true) + }) + it('does not treat an open subagent lane as an executing tool row', () => { - expect(assistantMessageHasVisibleExecutingTool([subagentStart('workflow', 'S1', 'main')])).toBe( - false - ) + expect( + assistantMessageHasVisibleExecutingTool( + parseBlocks([subagentStart('workflow', 'S1', 'main')]) + ) + ).toBe(false) }) it('keeps a visible executing tool as active work', () => { @@ -803,7 +821,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => { timestamp: 3, }, ] - expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(true) + expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(true) }) it('does not let open parallel lanes suppress the single turn-level indicator', () => { @@ -811,7 +829,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => { subagentStart('workflow', 'S1', 'main'), subagentStart('search', 'S2', 'main'), ] - expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) + expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false) }) it('ignores the executing dispatch tool represented by its subagent lane', () => { @@ -826,10 +844,83 @@ describe('assistantMessageHasVisibleExecutingTool', () => { parentToolCallId: 'dispatch-1', }, ] - expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) + expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false) }) }) +describe('parseBlocks main activity controls', () => { + it.each([undefined, 'main'])( + 'retains interaction controls and answers across prose and completion with spanId=%s', + (spanId) => { + const blocks: ContentBlock[] = [ + { + type: 'tool_call', + toolCall: { id: 'permission', name: 'read', status: 'awaiting_approval' }, + spanId, + timestamp: 1, + }, + mainText('A permission decision is pending.'), + { + type: 'tool_call', + toolCall: { + id: 'handoff', + name: 'terminal', + status: 'executing', + params: { operation: 'handoff' }, + }, + timestamp: 2, + }, + { + type: 'tool_call', + toolCall: { + id: 'answered-takeover', + name: 'browser_request_takeover', + status: 'success', + params: { reason: 'Choose a result.' }, + result: { success: true, output: { userInstruction: 'Open the second result.' } }, + }, + timestamp: 2, + }, + mainToolCall('older', 'grep'), + mainText('Checking another source.'), + { + type: 'tool_call', + toolCall: { id: 'latest', name: 'read', status: 'executing' }, + timestamp: 3, + }, + ] + + const visibleTools = (content: ContentBlock[]) => + parseBlocks(content).flatMap((segment) => + segment.type === 'agent_group' + ? segment.items.flatMap((item) => (item.type === 'tool' ? [item.data] : [])) + : [] + ) + + expect(visibleTools(blocks).map((tool) => tool.id)).toEqual([ + 'permission', + 'handoff', + 'answered-takeover', + 'older', + 'latest', + ]) + const completed = blocks.map((block) => + block.toolCall?.id === 'latest' + ? { ...block, toolCall: { ...block.toolCall, status: 'success' as const } } + : block + ) + expect(visibleTools(completed).map((tool) => tool.id)).toEqual([ + 'permission', + 'handoff', + 'answered-takeover', + 'older', + 'latest', + ]) + expect(visibleTools(completed).at(-1)?.status).toBe('success') + } + ) +}) + describe('deriveThinkingLabel', () => { it('maps the most recent block to an activity phrase', () => { expect(deriveThinkingLabel([])).toBe('Thinking…') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 7828f6b187d..54931cf07dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -283,12 +283,10 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { return last?.type === 'agent_group' && last.agentName === 'mothership' ? last : null } - // Top-level (mothership) tool calls render in a collapsible group. Reuse that - // group only while it is still the most recent segment so consecutive tools - // stay together; once another visible segment (main text or a spawned - // subagent) breaks the run, the next tool opens a fresh group below it - // instead of jumping back up into the original one. This keeps the mothership's - // tools and prose interleaved in the order they actually happened. + /** + * Reuse only the latest main activity segment so tools remain interleaved + * with prose and subagents in stream order. + */ const ensureMothership = (): AgentGroupSegment => { const existing = tailMothershipGroup() if (existing) return existing @@ -492,7 +490,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { * Groups content blocks into agent-scoped segments. * Dispatch tool_calls (name matches a subagent key, no calledBy) are absorbed * into the agent header. Inner tool_calls are nested underneath their agent. - * Orphan tool_calls (no calledBy, not a dispatch) group under "Sim". + * Main-agent segments retain their tool history for inline activity summaries. * * New backends stamp every subagent block with deterministic span identity; in * that case {@link parseBlocksWithSpanTree} builds a real nested tree. The @@ -500,10 +498,9 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { * span identity existed. */ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { - if (blocks.some((block) => Boolean(block.spanId))) { - return parseBlocksWithSpanTree(blocks) - } - return parseBlocksLegacy(blocks) + return blocks.some((block) => Boolean(block.spanId)) + ? parseBlocksWithSpanTree(blocks) + : parseBlocksLegacy(blocks) } function joinRenderableText(parts: string[]): string { @@ -762,22 +759,17 @@ export function assistantMessageHasRenderableContent( } /** True when the transcript is already rendering an executing tool row. */ -export function assistantMessageHasVisibleExecutingTool(blocks: ContentBlock[]): boolean { - const subagentDispatchCallIds = new Set() - for (const block of blocks) { - if (block.type === 'subagent' && block.parentToolCallId) { - subagentDispatchCallIds.add(block.parentToolCallId) - } - } - - return blocks.some((block) => { - const toolCall = block.toolCall - if (!toolCall || toolCall.status !== 'executing') return false - if (isHiddenToolCall(toolCall.name)) return false - if (toolCall.name === ReadTool.id && isToolResultRead(toolCall.params)) return false - if (SUBAGENT_KEYS.has(toolCall.name)) return false - return !subagentDispatchCallIds.has(toolCall.id) - }) +export function assistantMessageHasVisibleExecutingTool(segments: MessageSegment[]): boolean { + const hasExecutingTool = (items: AgentGroupItem[]): boolean => + items.some((item) => + item.type === 'tool' + ? item.data.status === 'executing' + : item.type === 'agent_group' && hasExecutingTool(item.group.items) + ) + + return segments.some( + (segment) => segment.type === 'agent_group' && hasExecutingTool(segment.items) + ) } export function shouldSmoothTextSegment({ @@ -962,7 +954,7 @@ function MessageContentInner({ // A mid-stream special tag renders nothing until complete, so its bytes are a // wait, not output — the shimmer bridges it without the quiet-period delay. const thinkingLabel = deriveThinkingLabel(blocks) - const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) + const hasExecutingTool = assistantMessageHasVisibleExecutingTool(segments) const showShimmer = thinkingExpanded && thinkingLabel !== null && @@ -1024,7 +1016,6 @@ function MessageContentInner({ items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} - isCurrentSection={i === segments.length - 1} isLaneOpen={segment.isOpen} />
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts index d4094191237..08d4e4a85c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts @@ -1,9 +1,15 @@ /** * @vitest-environment node */ +import { Blimp, Wrench } from '@sim/emcn' import { describe, expect, it } from 'vitest' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' -import { deriveMessagePhase, resolveToolDisplayState } from './utils' +import { + deriveMessagePhase, + getAgentIcon, + getToolIcon, + resolveToolDisplayState, +} from '@/app/workspace/[workspaceId]/home/components/message-content/utils' describe('deriveMessagePhase', () => { it('is streaming whenever the transport is live', () => { @@ -59,3 +65,13 @@ describe('collectMessageSources', () => { expect(collectMessageSources(['Plain prose.', ''])).toEqual([]) }) }) + +describe('unknown activity icons', () => { + it.each(['future_tool', '', 'constructor', 'toString', '__proto__'])( + 'uses fallback icons for %s', + (name) => { + expect(getToolIcon(name)).toBe(Wrench) + expect(getAgentIcon(name)).toBe(Blimp) + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 722770fbfa9..cf640924e52 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -19,97 +19,178 @@ import { TerminalWindow, Wrench, } from '@sim/emcn' -import { Calendar, Clock, Cursor, Globe, Table as TableIcon } from '@sim/emcn/icons' -import { AgentIcon, ImageIcon, TTSIcon, VideoIcon } from '@/components/icons' +import { + Brain, + Calendar, + Clock, + Cursor, + Globe, + ImageUp, + Music, + Square, + Table as TableIcon, +} from '@sim/emcn/icons' import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' export type IconComponent = ComponentType> -const TOOL_ICONS: Record = { - mothership: Blimp, - glob: FolderCode, - grep: Search, - read: File, - mv: FolderCode, - cp: Layout, - mkdir: FolderCode, - web_search: Search, - web_scrape: Search, - web_fetch: Search, - search_library_docs: Library, - manage_mcp_connection: Settings, - manage_skill: Asterisk, - user_memory: Database, - run_function: TerminalWindow, - run_code: TerminalWindow, - superagent: Blimp, - user_table: TableIcon, - prepare_file_edit: File, +export const TOOL_ICONS: Readonly> = { + agent: Brain, apply_file_edit: File, - create_workflow: Layout, - edit_workflow: Pencil, - workflow: Hammer, - debug: Bug, - run: PlayOutline, - deploy: Rocket, auth: Integration, - knowledge: Database, - knowledge_base: Database, - search_knowledge_base: Database, - table: TableIcon, - query_user_table: TableIcon, - job: Calendar, - agent: AgentIcon, - custom_tool: Wrench, - research: Search, - scout: Search, - search: Search, - platform: Library, - context_compaction: Asterisk, - open_resource: Eye, - file: File, - media: VideoIcon, - generate_image: ImageIcon, - generate_video: VideoIcon, - generate_audio: TTSIcon, - ffmpeg: Wrench, browser: Globe, - browser_navigate: Cursor, + browser_click: Cursor, + browser_click_at: Cursor, + browser_close_tab: Cursor, + browser_drag: Cursor, + browser_extract: Search, + browser_fill_form: Pencil, + browser_find: Search, browser_go_back: Cursor, browser_go_forward: Cursor, - browser_reload: Cursor, - browser_open_tab: Cursor, - browser_switch_tab: Cursor, - browser_close_tab: Cursor, - browser_list_tabs: Cursor, - browser_list_sessions: Globe, + browser_hover: Cursor, + browser_insert_text: Pencil, browser_list_downloads: File, - browser_wait_for: Cursor, - browser_snapshot: Eye, - browser_find: Search, + browser_list_sessions: Globe, + browser_list_tabs: Cursor, + browser_navigate: Cursor, + browser_open_tab: Cursor, + browser_open_url: Cursor, + browser_press_key: Cursor, browser_read_text: File, + browser_reload: Cursor, + browser_request_takeover: Cursor, browser_screenshot: Eye, - browser_extract: Search, - browser_click: Cursor, - browser_type: Pencil, - browser_press_key: Cursor, browser_scroll: Cursor, browser_select_option: Cursor, browser_set_checked: Cursor, - browser_hover: Cursor, + browser_snapshot: Eye, + browser_switch_tab: Cursor, + browser_type: Pencil, + browser_wait_for: Cursor, browser_zoom: Eye, - browser_request_takeover: Cursor, + call_integration_tool: Integration, + cancel_workflow_run: Square, + connect_slack_bot: Integration, + context_compaction: Asterisk, + cp: Layout, + create_empty_file: File, + create_workflow: Layout, + create_workspace_mcp_server: Integration, + custom_tool: Wrench, + debug: Bug, + delete_workspace_mcp_server: Integration, + deploy: Rocket, + deploy_as_api: Rocket, + deploy_as_chat: Rocket, + deploy_as_mcp: Rocket, + diff_workflows: Layout, + download_file: File, + edit_workflow: Pencil, + extensions: Brain, + extract_doc_assets: File, + ffmpeg: Wrench, + file: File, + generate_api_key: Settings, + generate_audio: Music, + generate_image: ImageUp, + generate_video: PlayOutline, + get_block_outputs: Layout, + get_block_upstream_references: Layout, + get_deployed_workflow_state: Rocket, + get_deployment_status: Rocket, + get_workflow_data: Layout, + get_workflow_run_options: PlayOutline, + glob: FolderCode, + grep: Search, + interrupt_agent: Brain, + job: Calendar, + knowledge: Database, + knowledge_base: Database, + list_deployment_versions: Rocket, + list_integration_tools: Integration, + list_integrations: Integration, + list_workspace_mcp_servers: Integration, + load_deployment: Rocket, + manage_credential: Settings, + manage_custom_tool: Wrench, + manage_knowledge_base: Database, + manage_mcp_connection: Settings, + manage_sandbox: TerminalWindow, + manage_skill: Asterisk, + media: PlayOutline, + mkdir: FolderCode, + mothership: Blimp, + mv: FolderCode, + oauth_get_auth_link: Integration, + oauth_request_access: Integration, + open_resource: Eye, + platform: Library, + prepare_file_edit: File, + promote_to_live: Rocket, + publish_custom_block: Wrench, + query_logs: TerminalWindow, + query_user_table: TableIcon, + read: File, + read_document: File, + redeploy: Rocket, + research: Search, + restore_resource: FolderCode, + rm: FolderCode, + run: PlayOutline, + run_block: PlayOutline, + run_code: TerminalWindow, + run_enrichment: TableIcon, + run_from_block: PlayOutline, + run_function: TerminalWindow, + run_workflow: PlayOutline, + run_workflow_until_block: PlayOutline, + save_upload: File, + scout: Search, + search: Search, + search_docs: Library, + search_knowledge_base: Database, + search_library_docs: Library, + search_workspace: Search, + set_block_enabled: Pencil, + set_environment_variables: Settings, + set_global_workflow_variables: Settings, + share_file: File, + steer_agent: Brain, + superagent: Blimp, + table: TableIcon, + table_automations: TableIcon, + table_columns: TableIcon, + table_enrichments: TableIcon, + table_manage: TableIcon, + table_rows: TableIcon, + table_views: TableIcon, + tail_agent: Brain, terminal: TerminalWindow, - terminal_run: TerminalWindow, + terminal_cwd: TerminalWindow, terminal_input: TerminalWindow, - terminal_read: TerminalWindow, terminal_kill: TerminalWindow, - terminal_cwd: TerminalWindow, + terminal_read: TerminalWindow, + terminal_run: TerminalWindow, + update_deployment_version: Rocket, + update_workspace_mcp_server: Integration, + user_memory: Database, + user_table: TableIcon, wait: Clock, + wait_agents: Clock, + web_crawl: Search, + web_fetch: Search, + web_scrape: Search, + web_search: Search, + workflow: Hammer, } export function getAgentIcon(name: string): IconComponent { - return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp + return Object.hasOwn(TOOL_ICONS, name) ? TOOL_ICONS[name] : Blimp +} + +export function getToolIcon(name: string): IconComponent { + return Object.hasOwn(TOOL_ICONS, name) ? TOOL_ICONS[name] : Wrench } export type MessagePhase = 'streaming' | 'revealing' | 'settled' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 6fc88b74a04..07254879b4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -4,7 +4,6 @@ import type { ComponentType } from 'react' import { memo, type ReactNode, - type RefObject, useCallback, useDeferredValue, useEffect, @@ -69,14 +68,6 @@ interface MothershipChatProps { composer?: ReactNode messages: ChatMessage[] isSending: boolean - /** The composer's Search-mode results, shown above the input. */ - searchResults?: ReactNode - /** The live search query; the composer shows it so the box and the results never disagree. */ - searchQuery?: string - /** The composer, for a caller that hands a question to the agent from outside the box. */ - userInputRef?: RefObject - /** Puts the composer in the mode a queued message was written in, when one is loaded for editing. */ - onRestoreQueuedMode?: (requestMode: QueuedMessage['requestMode']) => void isReconnecting?: boolean isLoading?: boolean onSubmit: ( @@ -84,12 +75,6 @@ interface MothershipChatProps { fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[] ) => void - /** Whether the composer offers Search mode; only the Home composer answers a search. */ - canSearch?: boolean - /** Off in Search mode, where the query stays put so the person can refine it. */ - clearOnSubmit?: boolean - /** Fires when the composer's text goes from something to nothing. */ - onCleared?: () => void onStopGeneration: () => void messageQueue: QueuedMessage[] editingQueuedId: string | null @@ -341,16 +326,9 @@ export function MothershipChat({ composer, messages: messagesProp, isSending, - searchResults, - searchQuery, - userInputRef: userInputRefProp, - onRestoreQueuedMode, isReconnecting = false, isLoading = false, onSubmit, - canSearch = false, - clearOnSubmit, - onCleared, onStopGeneration, messageQueue, editingQueuedId, @@ -701,8 +679,7 @@ export function MothershipChat({ item.index !== lastIndex && item.start < (instance.scrollElement?.scrollTop ?? 0) const scrolledChatRef = useRef(UNSCROLLED) - const ownUserInputRef = useRef(null) - const userInputRef = userInputRefProp ?? ownUserInputRef + const userInputRef = useRef(null) const messageQueueRef = useRef(messageQueue) useEffect(() => { messageQueueRef.current = messageQueue @@ -726,10 +703,9 @@ export function MothershipChat({ (id: string) => { const msg = onEditQueuedMessage(id) if (!msg) return - onRestoreQueuedMode?.(msg.requestMode) userInputRef.current?.loadQueuedMessage(msg) }, - [onEditQueuedMessage, onRestoreQueuedMode, userInputRef] + [onEditQueuedMessage, userInputRef] ) const handleEditQueuedTail = useCallback(() => { @@ -860,9 +836,6 @@ export function MothershipChat({ onAnimationEnd={animateInput ? onInputAnimationEnd : undefined} >
- {searchResults && ( -
{searchResults}
- )} onAdd: (resource: MothershipResource) => void - onOpenExisting?: (resource: MothershipResource) => void /** * Resource types to hide from the dropdown. Must be referentially stable * (a module constant) — it keys the underlying group memo. @@ -303,14 +306,14 @@ export function useAvailableResources( }), }, ] - // The live browser panel — desktop app only (needs the agent-browser - // bridge). There is one top-level panel; repeated launches open inner tabs. + // A new browser tab — desktop app only (needs the agent-browser bridge). + // Every launch opens another page; the strip lists each as its own tab. if (browserAvailable) { groups.push({ type: 'browser' as const, items: [ { - id: BROWSER_SESSION_RESOURCE_ID, + id: BROWSER_LAUNCHER_ID, name: 'Browser', }, ], @@ -323,7 +326,7 @@ export function useAvailableResources( type: 'terminal' as const, items: [ { - id: TERMINAL_SESSION_RESOURCE_ID, + id: TERMINAL_LAUNCHER_ID, name: 'Terminal', }, ], @@ -541,9 +544,13 @@ export function ResourceMenuSections({ const Icon = config.icon const section = sectionByType.get(type) - // Browser and terminal each have one top-level panel — a flat launcher - // here creates inner tabs when that panel already exists. - if (!section && (type === 'browser' || type === 'terminal')) { + // The Browser and Terminal launchers are flat rows that open a new page + // or shell. Live pages and shells offered as context are an ordinary + // picker submenu. + if ( + !section && + (items[0]?.id === BROWSER_LAUNCHER_ID || items[0]?.id === TERMINAL_LAUNCHER_ID) + ) { const item = items[0] return ( onSelect(resourceFromItem(type, item))}> @@ -587,9 +594,7 @@ export function ResourceMenuSections({ export function AddResourceDropdown({ workspaceId, - existingKeys, onAdd, - onOpenExisting, excludeTypes, onRequestOpen, onClose, @@ -638,13 +643,7 @@ export function AddResourceDropdown({ } const select = (resource: MothershipResource) => { - void closeMenu().then(() => { - if (onOpenExisting && existingKeys.has(`${resource.type}:${resource.id}`)) { - onOpenExisting(resource) - } else { - onAdd(resource) - } - }) + void closeMenu().then(() => onAdd(resource)) } const filtered = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts index 0b593280917..8d386e2156b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts @@ -32,7 +32,6 @@ export type BrowserPanelOverlay = | 'downloads' | 'resources' | 'suggestions' - | 'tab' | 'toolbar' export interface BrowserPanelOverlayController { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts index e9fa882f8d7..3275c93b6ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts @@ -13,12 +13,10 @@ import { claimPermissionResponse, clearOmniboxSelection, exceededOmniboxDragThreshold, - hasConfirmedBrowserTabCreation, initialUrlSuggestionIndex, resolveUrlBarInput, selectFocusedOmniboxOnNextFrame, shouldOpenUrlSuggestions, - shouldRemoveBrowserResource, shouldReportBrowserBounds, shouldShowBrowserPermissionRequest, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' @@ -424,21 +422,7 @@ describe('initialUrlSuggestionIndex', () => { }) }) -describe('hasConfirmedBrowserTabCreation', () => { - it('requires both a larger strip and a distinct active tab', () => { - expect(hasConfirmedBrowserTabCreation('tab-1', 1, 'tab-2', 2)).toBe(true) - expect(hasConfirmedBrowserTabCreation('tab-1', 1, 'tab-1', 2)).toBe(false) - expect(hasConfirmedBrowserTabCreation('tab-1', 1, 'tab-2', 1)).toBe(false) - expect(hasConfirmedBrowserTabCreation('tab-1', 1, null, 2)).toBe(false) - }) -}) - describe('suspended browser resource lifecycle', () => { - it('does not remove a resource when administrative suspension clears its tabs', () => { - expect(shouldRemoveBrowserResource(false, true, true)).toBe(false) - expect(shouldRemoveBrowserResource(false, true, false)).toBe(true) - }) - it('reports native bounds only while visible and unsuspended', () => { expect(shouldReportBrowserBounds(true, false)).toBe(true) expect(shouldReportBrowserBounds(true, true)).toBe(false) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index d6a9aa5af31..f3aa734a126 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -8,7 +8,6 @@ import type { BrowserPanelBounds, BrowserPanelSnapshot, BrowserSitePermissionRequest, - BrowserTabState, } from '@sim/browser-protocol' import { isBrowserTheme } from '@sim/browser-protocol' import type { @@ -33,7 +32,6 @@ import { PopoverAnchor, PopoverContent, PopoverItem, - toast, } from '@sim/emcn' import { ArrowLeft, ArrowRight, Globe, Key, Link, RefreshCw, Search } from '@sim/emcn/icons' import { useTheme } from 'next-themes' @@ -53,20 +51,15 @@ import { onBrowserFindOpen, onBrowserOmniboxFocus, onBrowserToolbarCommand, - openBrowserTab, - reorderBrowserTab, reportBrowserPanelBounds, reportBrowserPanelFocused, reportBrowserTheme, sendBrowserPanelAction, setBrowserPanelOccluded, - setBrowserTabPinned, showBrowserCredentialChooser, - showBrowserTabContextMenu, showBrowserToolbarMenu, supportsAtomicBrowserPanelOcclusion, } from '@/lib/browser-agent/transport' -import { BROWSER_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types' import { faviconUrl } from '@/lib/core/utils/favicon' import { getDesktopBridge } from '@/lib/desktop' import { @@ -75,7 +68,6 @@ import { } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' import { addMothershipContext } from '@/lib/mothership/events' -import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' import { BrowserDownloads } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads' import { BrowserFindBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar' import { BrowserLoadingBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-loading-bar' @@ -89,7 +81,6 @@ import { mutationsTouchNativeSurfaceOcclusion, useBrowserPanelOcclusion, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' -import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip' import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice' import { buildOmniboxSuggestions, @@ -110,9 +101,7 @@ import type { ChatContext } from '@/stores/panel' /** Ties the omnibox to its listbox for assistive tech. */ const SUGGESTIONS_LIST_ID = 'browser-url-suggestions' const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160 -const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000 const OMNIBOX_DRAG_THRESHOLD_PX = 4 -const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const suggestionRowId = (index: number) => `${SUGGESTIONS_LIST_ID}-${index}` @@ -368,15 +357,6 @@ interface BrowserSessionProps { onOverlayControllerChange?: (controller: BrowserPanelOverlayController | null) => void } -/** Administrative suspension retains the resource even though live tabs are gone. */ -export function shouldRemoveBrowserResource( - sessionAlive: boolean, - observedLiveSession: boolean, - suspended: boolean -): boolean { - return !suspended && !sessionAlive && observedLiveSession -} - /** A suspended scope never leases native compositor bounds. */ export function shouldReportBrowserBounds(visible: boolean, suspended: boolean): boolean { return visible && !suspended @@ -421,23 +401,6 @@ export function initialUrlSuggestionIndex( return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null } -/** A new-tab request is complete only after the authoritative strip grows and activates a new id. */ -export function hasConfirmedBrowserTabCreation( - previousActiveTabId: string | null, - previousTabCount: number, - activeTabId: string | null, - tabCount: number -): boolean { - return tabCount > previousTabCount && activeTabId !== null && activeTabId !== previousActiveTabId -} - -interface PendingNewTabFocus { - scopeId: string - previousActiveTabId: string | null - previousTabCount: number - timeoutId: number -} - interface OmniboxPointerSelection { pointerId: number originX: number @@ -461,27 +424,9 @@ export function BrowserSession({ // panel. Direct selection prevents that one render from showing or acting on // another chat's tabs. const pageState = useBrowserSessionStore((state) => state.sessions[scopeId]?.pageState ?? null) - const tabs = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.tabs ?? EMPTY_BROWSER_TABS - ) const activeTabId = useBrowserSessionStore( (state) => state.sessions[scopeId]?.activeTabId ?? null ) - const automationTabId = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.automationTabId ?? null - ) - const automationActive = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.automationActive ?? false - ) - const automationNeedsAttention = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.automationNeedsAttention ?? false - ) - const browserAgentActive = useBrowserSessionStore( - (state) => (state.sessions[scopeId]?.agentRunIds.length ?? 0) > 0 - ) - const sessionAlive = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.sessionAlive ?? true - ) const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false) const showEmptyState = Boolean( pageState && @@ -507,42 +452,14 @@ export function BrowserSession({ const toolbarMenuButtonRef = useRef(null) const omniboxFocusRafRef = useRef(null) const omniboxPointerSelectionRef = useRef(null) - const pendingNewTabFocusRef = useRef(null) const handledPermissionRequestIdsRef = useRef>(new Set()) const [answeredPermissionRequestId, setAnsweredPermissionRequestId] = useState( null ) const visibleRef = useRef(visible) visibleRef.current = visible - const { removeResource } = useMothershipResources() const { navigateToSettings } = useSettingsNavigation() - // The browser session ending closes the panel, the way the terminal panel - // goes when its last shell does. What it leaves otherwise is a tab whose - // only content explains that there is nothing to show and that starting - // again has to happen from somewhere else — the agent reopens the panel on - // its next browser action anyway. Guarded on having seen a live session so - // that opening the panel while the store still remembers a closed one does - // not immediately close it again. - const observedLiveSession = useRef(false) - useEffect(() => { - if (suspended) { - observedLiveSession.current = false - return - } - if (sessionAlive) { - // A new scope starts optimistically alive until the desktop answers. - // Only a real tab proves that this panel observed a live session; - // otherwise the expected empty response from lazy activation would look - // like a user closing the last tab and delete the persisted resource - // before its encrypted descriptor gets a chance to hydrate. - if (tabs.length > 0) observedLiveSession.current = true - return - } - if (!shouldRemoveBrowserResource(sessionAlive, observedLiveSession.current, suspended)) return - observedLiveSession.current = false - removeResource('browser', BROWSER_SESSION_RESOURCE_ID) - }, [sessionAlive, suspended, tabs.length, removeResource]) const pageUrlRef = useRef(pageState?.url ?? '') pageUrlRef.current = pageState?.url ?? '' /** Non-null while the user is editing the URL bar; otherwise it mirrors the page. */ @@ -734,61 +651,18 @@ export function BrowserSession({ }) }, []) - const clearPendingNewTabFocus = useCallback((pending?: PendingNewTabFocus): boolean => { - const current = pendingNewTabFocusRef.current - if (!current || (pending && current !== pending)) return false - window.clearTimeout(current.timeoutId) - pendingNewTabFocusRef.current = null - return true - }, []) - - // New shells acknowledge tab creation directly; older installed shells only - // publish the resulting strip. Both paths land here so neither clears the - // current page's omnibox before a distinct tab actually exists. - useEffect(() => { - const pending = pendingNewTabFocusRef.current - if ( - !pending || - pending.scopeId !== scopeId || - !hasConfirmedBrowserTabCreation( - pending.previousActiveTabId, - pending.previousTabCount, - activeTabId, - tabs.length - ) - ) { - return - } - if (!clearPendingNewTabFocus(pending)) return - if (visible) focusOmnibox('clear') - }, [activeTabId, clearPendingNewTabFocus, focusOmnibox, scopeId, tabs.length, visible]) - - useEffect(() => { - return () => { - const pending = pendingNewTabFocusRef.current - if (pending?.scopeId === scopeId) clearPendingNewTabFocus(pending) - } - }, [clearPendingNewTabFocus, scopeId]) - useEffect(() => onBrowserOmniboxFocus(focusOmnibox, scopeId), [focusOmnibox, scopeId]) - // Follow the agent's tab. The panel already marks the automated tab in the - // strip; this makes it the VISIBLE one, so watching the agent never means - // hunting for which tab it moved to. Keyed on the automation target - // CHANGING, not on it merely being set — the user can still browse a - // different tab mid-run and is only pulled along when the agent itself - // moves to another tab. - const followedAutomationTabRef = useRef(null) + // A fresh blank tab coming on screen — opened from the resource strip or by + // Cmd+T — gets the omnibox, the way Chrome's new-tab page does. A tab with a + // page keeps its content. + const focusedBlankTabIdRef = useRef(null) useEffect(() => { - if (!automationActive || !automationTabId) { - if (!automationActive) followedAutomationTabRef.current = null - return - } - if (followedAutomationTabRef.current === automationTabId) return - followedAutomationTabRef.current = automationTabId - if (automationTabId === activeTabId) return - sendBrowserPanelAction('switch-tab', { tabId: automationTabId }, scopeId) - }, [activeTabId, automationActive, automationTabId, scopeId]) + if (!visible || !activeTabId || !showEmptyState) return + if (focusedBlankTabIdRef.current === activeTabId) return + focusedBlankTabIdRef.current = activeTabId + focusOmnibox('clear') + }, [activeTabId, focusOmnibox, showEmptyState, visible]) // Sim owns keyboard events while its renderer has focus. Claim Cmd+L here // before the workspace's global "Go to Logs" command can navigate away. @@ -1128,46 +1002,6 @@ export function BrowserSession({ urlInputRef.current?.blur() } - const handleNewTab = useCallback(() => { - setSuggestionsVisible(false) - setSuggestionQuery(null) - setActiveSuggestion(null) - setSuggestionOriginUrl('') - clearPendingNewTabFocus() - const pending: PendingNewTabFocus = { - scopeId, - previousActiveTabId: activeTabId, - previousTabCount: tabs.length, - timeoutId: 0, - } - pending.timeoutId = window.setTimeout(() => { - if (clearPendingNewTabFocus(pending)) { - toast.error('Could not open a new browser tab. Please try again.') - } - }, NEW_TAB_CONFIRM_TIMEOUT_MS) - pendingNewTabFocusRef.current = pending - void openBrowserTab(scopeId) - .then((state) => { - // Older shells resolve null and confirm through the tab-state effect. - if (!state) return - if ( - !hasConfirmedBrowserTabCreation( - pending.previousActiveTabId, - pending.previousTabCount, - state.activeTabId, - state.tabs.length - ) - ) { - throw new Error('The desktop browser did not create a distinct tab.') - } - }) - .catch(() => { - if (clearPendingNewTabFocus(pending)) { - toast.error('Could not open a new browser tab. Please try again.') - } - }) - }, [activeTabId, clearPendingNewTabFocus, scopeId, tabs.length]) - /** * Opens the shell's native account chooser under the key icon. Called * directly from the click so the page still has an active user gesture, @@ -1196,70 +1030,9 @@ export function BrowserSession({ showBrowserToolbarMenu({ x: rect.left, y: rect.bottom }, scopeId) }, [scopeId]) - const handleSwitchTab = useCallback( - (tabId: string) => { - setSuggestionsVisible(false) - setSuggestionQuery(null) - setUrlDraft(null) - urlInputRef.current?.blur() - sendBrowserPanelAction('switch-tab', { tabId }, scopeId) - }, - [scopeId] - ) - - const handleCloseTab = useCallback( - (tabId: string) => { - setSuggestionsVisible(false) - setSuggestionQuery(null) - setUrlDraft(null) - urlInputRef.current?.blur() - sendBrowserPanelAction('close-tab', { tabId }, scopeId) - }, - [scopeId] - ) - - const handleDuplicateTab = useCallback( - (tabId: string) => { - sendBrowserPanelAction('duplicate-tab', { tabId }, scopeId) - }, - [scopeId] - ) - - const handleSetTabPinned = useCallback( - (tabId: string, pinned: boolean) => { - setBrowserTabPinned(tabId, pinned, scopeId) - }, - [scopeId] - ) - - const handleReorderTab = useCallback( - (tabId: string, targetIndex: number) => { - reorderBrowserTab(tabId, targetIndex, scopeId) - }, - [scopeId] - ) - return (
- - requestOverlay('tab', () => showBrowserTabContextMenu(tabId, scopeId)) - } - onCloseTabMenu={() => void closeOverlay('tab')} - onReorderTab={handleReorderTab} - contextMenuOpen={activeOverlay === 'tab'} - />
+ ), + }) +) + +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { AtlassianSourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal' + +let root: Root +let container: HTMLDivElement +function button(label: string) { + const found = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent?.trim() === label || node.getAttribute('aria-label') === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} +function input(placeholder: string) { + const found = document.querySelector(`input[placeholder="${placeholder}"]`) + if (!found) throw new Error(`Missing input: ${placeholder}`) + return found +} +function fill(field: HTMLInputElement, value: string) { + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(field, value) + field.dispatchEvent(new Event('input', { bubbles: true })) + }) +} +function render(type: 'jira' | 'confluence' = 'jira') { + const connector = SEARCH_CONNECTORS.find((item) => item.type === type)! + act(() => + root.render( + + + + ) + ) +} +beforeEach(() => { + vi.clearAllMocks() + m.accounts = [] + m.pending = false + m.connect.mockResolvedValue({ + kind: 'connected', + connectorId: 'source-1', + knowledgeBaseId: 'kb-1', + }) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) +describe('Atlassian personal source setup', () => { + it.each(['jira', 'confluence'] as const)( + 'starts %s authorization from the account dropdown', + (type) => { + render(type) + expect(document.querySelector('[data-testid="picker"]')).toBeNull() + expect(button('Connect & Sync')).toBeDisabled() + expect(document.body.textContent).not.toContain( + `Connect ${type === 'jira' ? 'Jira' : 'Confluence'} account` + ) + act(() => document.querySelector('[role="combobox"]')!.click()) + const connectOption = document.querySelector('[role="option"]')! + expect(connectOption.textContent).toContain( + `Connect ${type === 'jira' ? 'Jira' : 'Confluence'} account` + ) + act(() => connectOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) + expect(m.authorize).toHaveBeenCalledOnce() + expect(m.connect).not.toHaveBeenCalled() + } + ) + it.each(['jira', 'confluence'] as const)( + 'reuses an account and preserves selected %s keys through manual mode and submission', + async (type) => { + m.accounts = [ + { id: 'my-account', name: 'My account', provider: type, type: 'managed_oauth', scopes: [] }, + ] + render(type) + fill(input('yoursite.atlassian.net'), 'team.atlassian.net') + const picker = document.querySelector('[data-testid="picker"]')! + expect(picker.dataset.credential).toBe('my-account') + expect(picker.dataset.surface).toBe('personal-search-setup') + act(() => picker.click()) + act(() => button(`Switch ${type === 'jira' ? 'Projects' : 'Spaces'} to manual input`).click()) + const manual = document.querySelector( + `input[aria-label="${type === 'jira' ? 'Project Keys' : 'Space Keys'}"]` + )! + expect(manual.value).toBe('ENG, SUPPORT') + fill(manual, 'ENG, PRODUCT') + act(() => + button(`Switch ${type === 'jira' ? 'Project Keys' : 'Space Keys'} to selector`).click() + ) + expect(document.querySelector('[data-testid="picker"]')?.textContent).toBe('ENG,PRODUCT') + await act(async () => button('Connect & Sync').click()) + expect(m.connect).toHaveBeenCalledWith({ + action: 'connect', + organizationId: 'org-1', + connectorType: type, + credentialId: 'my-account', + domain: 'team.atlassian.net', + keys: ['ENG', 'PRODUCT'], + }) + expect(m.authorize).not.toHaveBeenCalled() + expect(m.complete).toHaveBeenCalledWith({ + connectorId: 'source-1', + credentialId: 'my-account', + }) + } + ) + it('clears the selection when the site changes', () => { + m.accounts = [ + { id: 'my-account', name: 'My account', provider: 'jira', type: 'managed_oauth', scopes: [] }, + ] + render() + fill(input('yoursite.atlassian.net'), 'team.atlassian.net') + act(() => document.querySelector('[data-testid="picker"]')!.click()) + fill(input('yoursite.atlassian.net'), 'other.atlassian.net') + expect(button('Connect & Sync')).toBeDisabled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.tsx new file mode 100644 index 00000000000..981d7439b3a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.tsx @@ -0,0 +1,254 @@ +'use client' + +import { useState } from 'react' +import { + Button, + Chip, + ChipCombobox, + ChipInput, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + Tooltip, + toast, +} from '@sim/emcn' +import { ArrowLeftRight, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { PersonalSourceSetupQuery } from '@/lib/api/contracts/knowledge/personal-source-setup' +import type { SearchConnector } from '@/lib/sim-search/connectors' +import { MAX_PERSONAL_SOURCE_SETUP_KEYS } from '@/lib/sim-search/personal-source-setup' +import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field' +import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' +import { useConnectPersonalSourceSetup } from '@/hooks/queries/personal-source-setup' +import { usePersonalSourceAccount } from '@/hooks/use-personal-source-account' + +interface AtlassianSourceSetupModalProps { + organizationId: string + connector: SearchConnector + connectorType: PersonalSourceSetupQuery['connectorType'] + onClose: () => void + onConnected?: (connection: { connectorId: string; credentialId: string }) => void +} + +export function AtlassianSourceSetupModal({ + organizationId, + connector, + connectorType, + onClose, + onConnected, +}: AtlassianSourceSetupModalProps) { + const account = usePersonalSourceAccount({ + organizationId, + connectorType, + onConnected: (id) => { + setSelectedAccount(id) + config.setSourceConfig((previous) => ({ domain: previous.domain ?? '' })) + }, + }) + const { mutateAsync: connect, isPending } = useConnectPersonalSourceSetup() + const config = useConnectorConfigFields({ + connectorConfig: connector.meta, + accessMode: 'members', + }) + const [selectedAccount, setSelectedAccount] = useState() + const accounts = account.accounts.data?.accounts ?? [] + const requestedAccount = + selectedAccount ?? + account.accounts.data?.completedCredentialId ?? + (accounts.length === 1 ? accounts[0].id : undefined) + const credentialId = accounts.find((item) => item.id === requestedAccount)?.id ?? null + const canonicalId = connectorType === 'jira' ? 'projectKey' : 'spaceKey' + const picker = connector.meta.configFields.find( + (field) => field.canonicalParamId === canonicalId && field.type === 'selector' + )! + const manual = connector.meta.configFields.find((field) => field.id === canonicalId)! + const advanced = config.canonicalModes[canonicalId] === 'advanced' + const domain = typeof config.sourceConfig.domain === 'string' ? config.sourceConfig.domain : '' + const resolved = config.resolveSourceConfig()[canonicalId] + const manualValue = config.sourceConfig[canonicalId] + const keys = Array.isArray(resolved) + ? resolved + .filter((key): key is string => typeof key === 'string' && Boolean(key.trim())) + .map((key) => key.trim()) + : [] + const pending = isPending || account.pending + const close = () => { + if (!isPending) onClose() + } + const chooseAccount = (id: string) => { + if (id === credentialId) return + setSelectedAccount(id) + config.setSourceConfig({ domain }) + } + const addAccount = () => { + void account.connect() + } + const submit = async () => { + if (!credentialId || !domain.trim() || !keys.length || pending) return + if (keys.length > MAX_PERSONAL_SOURCE_SETUP_KEYS) { + toast.error('Choose no more than 1,000 projects or spaces per source.') + return + } + try { + const result = await connect({ + action: 'connect', + organizationId, + connectorType, + credentialId, + domain: domain.trim(), + keys, + }) + onConnected?.({ connectorId: result.connectorId, credentialId }) + onClose() + } catch (error) { + toast.error(getErrorMessage(error, 'Could not connect the source')) + } + } + + return ( + { + if (!open) close() + }} + srTitle={`Connect ${connector.meta.name}`} + > + Connect {connector.meta.name} + + + {(aria) => ( + <> + ({ value: item.id, label: item.name })), + { + value: '__connect_new__', + label: `Connect ${connector.meta.name} account`, + icon: Plus, + onSelect: addAccount, + }, + ]} + placeholder={ + account.pending + ? 'Waiting for authorization…' + : account.accounts.isPending + ? 'Loading accounts…' + : 'Select your account' + } + disabled={pending || account.accounts.isPending} + /> + {account.pending && Cancel authorization} + {account.accounts.isError && ( + void account.accounts.refetch()}>Retry loading accounts + )} + + )} + + {credentialId && !account.pending && ( + <> + config.handleFieldChange('domain', value)} + placeholder='yoursite.atlassian.net' + autoComplete='off' + required + disabled={isPending} + /> + + + + + + {advanced ? 'Switch to selector' : 'Switch to manual input'} + + + } + > + {(aria) => ( + <> + {advanced ? ( + + config.handleFieldChange(canonicalId, event.target.value) + } + placeholder={manual.placeholder} + disabled={isPending} + /> + ) : picker.selectorKey ? ( + + config.handleFieldChange(picker.id, value, labels) + } + credentialId={credentialId} + sourceConfig={config.sourceConfig} + configFields={connector.meta.configFields} + canonicalModes={config.canonicalModes} + selectedLabels={config.selectionLabels[canonicalId]} + disabled={isPending} + /> + ) : null} + + )} + + + )} + + + window.open(connector.meta.searchDocsUrl, '_blank', 'noopener,noreferrer'), + }, + ] + : undefined + } + primaryAction={{ + label: isPending ? 'Connecting…' : 'Connect & Sync', + onClick: () => void submit(), + disabled: !credentialId || !domain.trim() || !keys.length || pending, + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/index.ts deleted file mode 100644 index daff1550541..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { isIndexing, SearchSources } from './search-sources' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx deleted file mode 100644 index 7ae8ef92de4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** @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 type { WorkspaceMemberConnector } from '@/lib/api/contracts/knowledge/connectors' - -const mocks = vi.hoisted(() => ({ - rows: vi.fn(), - admin: vi.fn(), - enabled: vi.fn(), - connect: vi.fn(), -})) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useOptionalWorkspaceHostContext: () => ({ features: { knowledgeMemberAccess: mocks.enabled() } }), -})) -vi.mock('@/hooks/queries/workspace', () => ({ - useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.admin() } } }), -})) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ - integrationAvailability: new Map([ - ['slack', { oauthAvailable: true, state: 'ready' }], - ['slack_v2', { oauthAvailable: true, state: 'ready' }], - ]), - oauthServiceAvailability: new Map( - [ - 'confluence', - 'google-drive', - 'google_drive', - 'google-email', - 'google-calendar', - 'jira', - 'github-repositories', - ].map((providerId) => [providerId, true]) - ), - isIntegrationAvailabilityReady: true, - isIntegrationAvailabilityLoading: false, - integrationAvailabilityError: null, - refetchIntegrationAvailability: vi.fn(), - }), -})) -vi.mock('@/hooks/queries/kb/connectors', () => ({ - useWorkspaceMemberConnectors: () => ({ data: mocks.rows() }), - memberConnectorKeys: { list: (id: string) => ['member-connectors', id] }, -})) -vi.mock('@/hooks/use-member-enrollment', () => ({ - CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), - useMemberEnrollment: () => ({ - connectSearchSource: mocks.connect, - isAwaiting: () => false, - isAwaitingSource: () => false, - isPending: false, - setupConnector: null, - }), -})) -vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal', () => ({ - SourceSetupModal: () => null, -})) -vi.mock('@/lib/integrations/credential-display', () => ({ - getIntegrationsForCredentialProvider: () => [], -})) -vi.mock('@/lib/oauth', () => ({ - getCanonicalScopesForProvider: () => [], - getServiceConfigByProviderId: () => undefined, - getServiceConfigByServiceId: (id: string) => ({ providerId: id, name: id, icon: () => null }), -})) -vi.mock('@/connectors/registry', () => ({ - CONNECTOR_META_REGISTRY: Object.fromEntries( - ['confluence', 'google_drive', 'slack'].map((id) => [ - id, - { - id, - name: id, - search: true, - icon: () => null, - auth: { mode: 'oauth', provider: id }, - permissionScopedListing: { capFieldIds: [] }, - configFields: [], - }, - ]) - ), -})) - -import { SearchSources } from '@/app/workspace/[workspaceId]/home/components/search-sources/search-sources' - -let container: HTMLDivElement -let root: Root -const source = (overrides: Partial = {}): WorkspaceMemberConnector => ({ - knowledgeBaseId: 'canonical-index', - knowledgeBaseName: 'Renamed company index', - knowledgeBaseIsSearchIndex: true, - connectorId: 'source-one', - connectorType: 'confluence', - sourceDescription: 'company.atlassian.net · ENG', - memberSyncStatus: 'idle', - viewerMembership: 'not_enrolled', - viewerDocumentCount: 0, - ...overrides, -}) -function mount(rows: WorkspaceMemberConnector[]) { - mocks.rows.mockReturnValue(rows) - act(() => root.render()) -} -function chips() { - return [...container.querySelectorAll('button')] -} -beforeEach(() => { - vi.clearAllMocks() - mocks.admin.mockReturnValue(false) - mocks.enabled.mockReturnValue(true) - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) -afterEach(() => { - act(() => root.unmount()) - container.remove() -}) - -describe('home Search source connections', () => { - it('lets a reader connect a configured source after the canonical index is renamed', () => { - const connection = source() - mount([connection]) - const chip = chips().find((button) => button.textContent === 'confluence')! - expect(chip.disabled).toBe(false) - act(() => chip.click()) - expect(mocks.connect).toHaveBeenCalledWith( - 'workspace', - expect.objectContaining({ type: 'confluence' }), - connection - ) - expect(chips().find((button) => button.textContent === 'google_drive')?.disabled).toBe(true) - }) - - it('keeps distinct configured sites visible and connects only the selected source', () => { - const first = source({ viewerMembership: 'connected', viewerDocumentCount: 2 }) - const second = source({ - connectorId: 'source-two', - sourceDescription: 'other.atlassian.net · OPS', - }) - mount([first, second]) - expect(container.textContent).toContain('company.atlassian.net · ENG') - expect(container.textContent).toContain('other.atlassian.net · OPS') - const chip = chips().find((button) => button.textContent?.includes('other.atlassian.net'))! - act(() => chip.click()) - expect(mocks.connect).toHaveBeenCalledExactlyOnceWith( - 'workspace', - expect.objectContaining({ type: 'confluence' }), - second - ) - }) - - it('does not use a same-named ordinary knowledge base as the canonical index', () => { - mount([source({ knowledgeBaseIsSearchIndex: false, knowledgeBaseName: 'Sim Search' })]) - expect(chips().every((button) => button.disabled)).toBe(true) - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('does not offer stale cached connections after member access is disabled', () => { - mocks.enabled.mockReturnValue(false) - mount([source({ viewerMembership: 'connected', viewerDocumentCount: 99 })]) - expect(container.textContent).not.toContain('99 documents') - expect(chips().every((button) => button.disabled)).toBe(true) - }) - - it.each(['revoked', 'unverified_email'] as const)( - 'does not re-enroll an account with %s access', - (viewerMembership) => { - mount([source({ viewerMembership })]) - const chip = chips().find((button) => button.textContent?.startsWith('confluence'))! - expect(chip.getAttribute('aria-disabled')).toBe('true') - act(() => chip.click()) - expect(mocks.connect).not.toHaveBeenCalled() - } - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx deleted file mode 100644 index de1e9194d8c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx +++ /dev/null @@ -1,226 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { Chip, chipContentGap, cn, OverflowText } from '@sim/emcn' -import { Loader, Plus } from '@sim/emcn/icons' -import { groupSearchConnections } from '@/lib/sim-search/connections' -import { - canConnectPersonally, - SEARCH_CONNECTORS, - type SearchConnector, - searchConnectorUnavailableReason, -} from '@/lib/sim-search/connectors' -import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' -import { BrandIcon } from '@/blocks/brand-icon' -import { - memberConnectorKeys, - useWorkspaceMemberConnectors, - type WorkspaceMemberConnector, -} from '@/hooks/queries/kb/connectors' -import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' -import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment' -import { usePermissionConfig } from '@/hooks/use-permission-config' - -const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = [] - -/** The sources a person can connect themselves, alphabetical. */ -const PERSONAL_SEARCH_CONNECTORS = SEARCH_CONNECTORS.filter((connector) => - canConnectPersonally(connector.meta) -) - -/** Whether a connected source is still indexing for the viewer. */ -export function isIndexing(connection: WorkspaceMemberConnector | undefined): boolean { - return ( - connection?.viewerMembership === 'connected' && - (connection.memberSyncStatus === 'pending' || connection.memberSyncStatus === 'running') - ) -} - -/** The chip's trailing state text for one source. */ -function sourceState( - connection: WorkspaceMemberConnector | undefined, - waiting: boolean -): string | null { - if (waiting) return 'Connecting…' - if (!connection) return null - switch (connection.viewerMembership) { - case 'connected': - return isIndexing(connection) - ? 'Indexing' - : connection.viewerDocumentCount === 1 - ? '1 document' - : `${connection.viewerDocumentCount} documents` - case 'needs_reauth': - return 'Reconnect' - case 'unverified_email': - return 'Verify email' - case 'revoked': - return 'Access removed' - default: - return null - } -} - -interface SourceChipProps { - connector: SearchConnector - connection: WorkspaceMemberConnector | undefined - showSource: boolean - /** Why the source cannot be connected here, shown as the chip's title; null when it can. */ - unavailableReason: string | null - waiting: boolean - disabled: boolean - onConnect: () => void -} - -function SourceChip({ - connector, - connection, - showSource, - unavailableReason, - waiting, - disabled, - onConnect, -}: SourceChipProps) { - const state = sourceState(connection, waiting) - const connected = connection?.viewerMembership === 'connected' - const unavailable = unavailableReason !== null - const actionable = - !unavailable && - !waiting && - (!connection || CONNECTABLE_MEMBERSHIPS.has(connection.viewerMembership)) - const name = - showSource && connection?.sourceDescription - ? `${connector.meta.name} · ${connection.sourceDescription}` - : connector.meta.name - const title = unavailableReason ?? (connected ? `${name}: ${state}` : `Connect ${name}`) - const busy = waiting || isIndexing(connection) - return ( - } - rightIcon={!busy && actionable ? Plus : undefined} - rightAdornment={ - busy ? : undefined - } - > - - - {state && {state}} - - - ) -} - -interface SearchSourcesProps { - workspaceId: string -} - -/** - * Every source a person can connect themselves, as chips under the composer: - * connected ones show how many documents they can read (or that indexing is - * still running), the rest connect with one click. A source that needs a site - * or space asks for it once, in place, on the connect that creates it; - * everyone after that clicks straight through. Sources an admin must set up - * as workspace connectors do not appear here. - */ -export function SearchSources({ workspaceId }: SearchSourcesProps) { - const { integrationAvailability, oauthServiceAvailability, isIntegrationAvailabilityReady } = - usePermissionConfig() - /** With per-member access off, a connect is refused, so the chips say so instead. */ - const memberAccessAvailable = useMemberAccessAvailable() - const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId) - /** The first connect of a source turns it on for the workspace, which takes an admin. */ - const canCreate = workspacePermissions?.viewer?.isAdmin ?? false - const { data: memberConnectorRows } = useWorkspaceMemberConnectors(workspaceId, { - enabled: memberAccessAvailable, - }) - /** Rows cached before the feature went off are not this surface's to show. */ - const memberConnectors = memberAccessAvailable - ? (memberConnectorRows ?? EMPTY_MEMBER_CONNECTORS) - : EMPTY_MEMBER_CONNECTORS - const { connectionByType } = useMemo( - () => groupSearchConnections(memberConnectors), - [memberConnectors] - ) - const connectedConnectorIds = useMemo( - () => - new Set( - memberConnectors - .filter((connector) => connector.viewerMembership === 'connected') - .map((connector) => connector.connectorId) - ), - [memberConnectors] - ) - const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId]) - const { - connectSource, - connectSearchSource, - setupConnector, - closeSetup, - isAwaiting, - isAwaitingSource, - isPending, - error, - } = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds }) - - /** Connected sources first; the catalog is already alphabetical, so the partition keeps the order. */ - const isConnected = (connector: SearchConnector) => - connectionByType.get(connector.type)?.some((source) => source.viewerMembership === 'connected') - const ordered = [ - ...PERSONAL_SEARCH_CONNECTORS.filter(isConnected), - ...PERSONAL_SEARCH_CONNECTORS.filter((connector) => !isConnected(connector)), - ] - - return ( -
-
- {ordered.flatMap((connector) => { - const connections = connectionByType.get(connector.type) ?? [] - return (connections.length ? connections : [undefined]).map((connection) => ( - 1} - unavailableReason={searchConnectorUnavailableReason( - connector, - integrationAvailability, - { - memberAccessAvailable, - hasConnection: connection !== undefined, - canCreate, - oauthServiceAvailability, - isIntegrationAvailabilityReady, - } - )} - waiting={ - connection ? isAwaiting(connection.connectorId) : isAwaitingSource(connector.type) - } - disabled={isPending} - onConnect={() => connectSearchSource(workspaceId, connector, connection)} - /> - )) - })} -
- {error &&

{error}

} - {setupConnector && ( - - connectSource(workspaceId, setupConnector.type, sourceConfig) - } - /> - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx index 4b73d5b989c..eb6f15699a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx @@ -9,8 +9,11 @@ import { ChipModalHeader, } from '@sim/emcn' import type { SearchConnector } from '@/lib/sim-search/connectors' +import { AtlassianSourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal' interface SourceSetupModalProps { + organizationId?: string + onConnected?: (connection: { connectorId: string; credentialId: string }) => void connector: SearchConnector onClose: () => void isPending?: boolean @@ -23,7 +26,26 @@ interface SourceSetupModalProps { * The few fields a source needs before its first connect, such as a site and * a space. Everyone after the first person clicks straight through. */ -export function SourceSetupModal({ +export function SourceSetupModal(props: SourceSetupModalProps) { + if ( + props.organizationId && + (props.connector.type === 'jira' || props.connector.type === 'confluence') + ) { + return ( + + ) + } + return +} + +function ManualSourceSetupModal({ connector, onClose, onConnect, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx index af71779f6b2..7194d0acac8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx @@ -5,22 +5,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCaptureEvent, modeState } = vi.hoisted(() => ({ +const { mockCaptureEvent } = vi.hoisted(() => ({ mockCaptureEvent: vi.fn(), - modeState: { initial: 'build', set: (_next: string) => {} }, })) -vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode', async () => { - const { useState } = await import('react') - return { - useMothershipMode: () => { - const [mode, setMode] = useState(modeState.initial) - modeState.set = setMode - return [mode, setMode] - }, - } -}) - vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) @@ -40,46 +28,9 @@ vi.mock('@/hooks/queries/tables', () => ({ vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }), })) -vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources', () => ({ - SearchSources: () =>
, -})) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ - integrationAvailability: new Map([['notion', { state: 'unavailable', oauthAvailable: false }]]), - }), -})) - -/** The Build-mode pool is built from the block catalog at module load; an empty catalog keeps it to the table starters. */ +/** The suggestion pool is built from the block catalog at module load; an empty catalog keeps it to the table starters. */ vi.mock('@/blocks/registry', () => ({ getAllBlockMeta: () => ({}), getAllBlocks: () => [] })) -vi.mock('@/lib/sim-search/connectors', () => { - const icon = () => null - const connector = (type: string, name: string, providerId: string) => ({ - type, - meta: { id: type, name, description: `Sync ${name}`, icon }, - providerId, - providerIds: [providerId], - requiredScopes: ['read'], - serviceName: name, - serviceIcon: icon, - blockType: type, - }) - return { - isSearchConnectorAvailable: ( - candidate: { blockType: string }, - availability: ReadonlyMap - ) => availability.get(candidate.blockType)?.oauthAvailable ?? true, - SEARCH_CONNECTORS: [ - connector('airtable', 'Airtable', 'airtable'), - connector('confluence', 'Confluence', 'confluence'), - connector('jira', 'Jira', 'jira'), - connector('jsm', 'Jira Service Management', 'jira'), - connector('notion', 'Notion', 'notion'), - connector('slack', 'Slack', 'slack'), - ], - } -}) - vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({ ConnectOAuthModal: ({ open, providerId }: { open: boolean; providerId: string }) => open ?
{providerId}
: null, @@ -112,7 +63,6 @@ function rows(): HTMLButtonElement[] { beforeEach(() => { onSelectPrompt.mockClear() mockCaptureEvent.mockClear() - modeState.initial = 'build' }) afterEach(() => { @@ -123,30 +73,18 @@ afterEach(() => { }) describe('SuggestedActions', () => { - it('shows the Build starters by default', () => { + it('shows suggested actions', () => { mount() expect(heading()).toBe('Suggested actions') expect(rows().map((row) => row.textContent)).toContain('Integrate with Slack') }) - it('shows every source in Search mode instead of the sampled suggestions', () => { + it('keeps suggestion actions interactive', () => { mount() - - act(() => modeState.set('search')) - - expect(heading()).toBe('Sources') - expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull() - expect(rows()).toHaveLength(0) - }) - - it('shows the sources in Assistant mode, which answers from them', () => { - mount() - - act(() => modeState.set('assistant')) - - expect(heading()).toBe('Sources') - expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull() - expect(rows()).toHaveLength(0) + const action = rows().find((row) => row.textContent === 'Create a CRM with sample data') + expect(action).toBeDefined() + act(() => action?.click()) + expect(onSelectPrompt).toHaveBeenCalledWith('Create a CRM with sample data.') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 1035e08e009..4901e6b2dca 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' +import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn' import { Table } from '@sim/emcn/icons' import { stripVersionSuffix } from '@sim/utils/string' import { useParams } from 'next/navigation' @@ -14,15 +14,12 @@ import { } from '@/lib/integrations' import { captureEvent } from '@/lib/posthog/client' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' -import { SearchSources } from '@/app/workspace/[workspaceId]/home/components/search-sources' import type { Action, ActionIcon, OAuthConnectTarget, } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types' import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' -import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params' import { BrandIcon } from '@/blocks/brand-icon' import { getAllBlockMeta } from '@/blocks/registry' import type { ModuleTag } from '@/blocks/types' @@ -30,7 +27,6 @@ import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections' import { useTablesList } from '@/hooks/queries/tables' -import { usePermissionConfig } from '@/hooks/use-permission-config' /** Lookup integration slug by OAuth service display name (case-insensitive). */ const SLUG_BY_LOWER_NAME: ReadonlyMap = new Map( @@ -232,13 +228,6 @@ const INITIAL_ACTIONS: Action[] = [ .map(toPromptAction), ] -/** Section heading per composer mode — Search reads as a connect-your-sources list. */ -const HEADINGS: Record = { - build: 'Suggested actions', - search: 'Sources', - assistant: 'Sources', -} - interface SuggestedActionsProps { onSelectPrompt: (prompt: string) => void } @@ -246,8 +235,6 @@ interface SuggestedActionsProps { export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { const { workspaceId } = useParams<{ workspaceId: string }>() const posthog = usePostHog() - const [mode] = useMothershipMode() - const { integrationAvailability } = usePermissionConfig() const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({ workspaceId, @@ -294,25 +281,11 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { [connectedProviders, tables.length, knowledgeBases.length] ) - /** - * Each mode's list is memoized on its own inputs alone, so switching modes — - * or the other mode's signals settling — never re-samples it. - * - * Search lists connectors to attach, and waits for the viewer's credentials: - * sampling against an empty set would list connected providers and then - * reshuffle when the query lands. Build lists personalized suggestions, - * re-sampled whenever signals resolve, and falls back to - * {@link INITIAL_ACTIONS} until the credential and service queries have loaded - * — and stays there for users with no connections — so first paint never - * flashes. The store's default mode is Build, so the server render never - * shows the sampled Search list. - */ - const buildActions = useMemo(() => { + const actions = useMemo(() => { const personalized = services.length > 0 && connectedProviders.size > 0 if (!personalized) return INITIAL_ACTIONS return computeActions(services, signals) }, [connectedProviders, services, signals]) - const actions = buildActions const handleSelect = (action: Action, position: number) => { captureEvent(posthog, 'suggested_action_clicked', { @@ -349,7 +322,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { aria-expanded={expanded} className='group/toggle flex w-full cursor-pointer items-center gap-2' > - {HEADINGS[mode]} + Suggested actions {/* * Revealed by hovering anywhere in the section — the group sits on the * section wrapper rather than this row, so the action rows below arm it just @@ -374,34 +347,30 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { `collapsible-up`/`-down` interpolate height alone, so a margin here would hold its full value through the close and then vanish on unmount, snapping the content below up. */} - {mode !== 'build' && workspaceId ? ( -
- -
- ) : ( -
- {actions.map((action, i) => { - const Icon = action.icon - return ( - - ) - })} -
- )} +
+ {actions.map((action, i) => { + const Icon = action.icon + return ( + + ) + })} +
{oauthTarget && workspaceId && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 15c8be8df73..a36370dd9b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -1,4 +1,5 @@ import { cn } from '@sim/emcn' +import { terminalIdFromResourceId } from '@/lib/terminal/resource-id' import type { MothershipResource, MothershipResourceType, @@ -109,15 +110,18 @@ export const SPEECH_RECOGNITION_LANG = 'en-US' * so adding a new resource type fails compilation here until a conversion is * supplied — preventing silent drift between the two taxonomies. */ -// Browser/terminal resources may name either the singleton panel or one live -// inner tab. The singleton ids ask the agent to inspect the whole resource; -// every other id is a precise live-tab pointer. +// A browser resource is one live page and a terminal resource one live shell, +// so each id is a precise pointer the agent can act on directly. const RESOURCE_TO_CONTEXT: Record< MothershipResourceType, (resource: MothershipResource) => ChatContext > = { browser: (r) => ({ kind: 'browser_tab', tabId: r.id, label: r.title }), - terminal: (r) => ({ kind: 'terminal_tab', terminalId: r.id, label: r.title }), + terminal: (r) => ({ + kind: 'terminal_tab', + terminalId: terminalIdFromResourceId(r.id), + label: r.title, + }), workflow: (r) => ({ kind: 'workflow', workflowId: r.id, label: r.title }), knowledgebase: (r) => ({ kind: 'knowledge', knowledgeId: r.id, label: r.title }), table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index 7d8bdca03af..95d472588c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -24,7 +24,6 @@ export { export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' export { MicrophonePermissionHelp } from './microphone-permission-help' -export { ModeSwitcher } from './mode-switcher' export { PlusMenuDropdown } from './plus-menu-dropdown' export type { PromptEditorInstance, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx index 8b78f27fe03..2e2b6a51cae 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx @@ -5,7 +5,7 @@ import { Button, cn, Tooltip, usePrefersReducedMotion } from '@sim/emcn' import { Mic } from '@sim/emcn/icons' const WAVEFORM_BAR_COUNT = 5 -const WAVEFORM_MIN_HEIGHT = 3 +const WAVEFORM_MIN_HEIGHT = 1 const WAVEFORM_MAX_HEIGHT = 14 const WAVEFORM_CENTER = 9 const WAVEFORM_EASING = 0.24 @@ -71,7 +71,7 @@ function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) { y2={WAVEFORM_CENTER + WAVEFORM_MIN_HEIGHT / 2} stroke='currentColor' strokeLinecap='round' - strokeWidth='1.7' + strokeWidth='1.5' /> ) })} @@ -108,6 +108,7 @@ export const MicButton = memo(function MicButton({ ({ - mockCaptureEvent: vi.fn(), - mockModeChange: vi.fn(), - mockPush: vi.fn(), - navigation: { - pathname: '/workspace/workspace-1/home', - chatId: undefined as string | undefined, - requestMode: undefined as 'agent' | 'assistant' | undefined, - }, -})) -const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() - -vi.mock('next/navigation', () => ({ - useParams: () => ({ workspaceId: 'workspace-1', chatId: navigation.chatId }), - usePathname: () => navigation.pathname, - useRouter: () => ({ push: mockPush }), -})) -/** The switcher renders only where Search mode exists, so these tests are that workspace. */ -vi.mock('@/hooks/use-member-access', () => ({ useMemberAccessAvailable: () => true })) -vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) -vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent })) -vi.mock('@/hooks/queries/mothership-chats', () => ({ - useMothershipChatHistory: () => ({ - data: navigation.chatId - ? { messages: [{ role: 'user', requestMode: navigation.requestMode }] } - : undefined, - }), -})) - -import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher' - -let root: Root | null = null -let container: HTMLDivElement | null = null - -function mount(searchParams = '') { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - act(() => - root?.render( - - - - ) - ) -} - -function trigger(): HTMLButtonElement { - const node = container?.querySelector('button') - if (!node) throw new Error('Switcher trigger did not render') - return node -} - -/** Opens the menu the way a pointer does — Radix opens on `pointerdown`. */ -function openMenu() { - act(() => { - trigger().dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) - }) -} - -function items(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="menuitem"]')) -} - -async function select(index: number) { - await act(async () => { - items()[index].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) - await vi.advanceTimersByTimeAsync(1) - }) -} - -beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - navigation.pathname = '/workspace/workspace-1/home' - navigation.chatId = undefined - navigation.requestMode = undefined - mockPush.mockClear() - mockModeChange.mockClear() - mockCaptureEvent.mockClear() - mockUrlUpdate.mockClear() -}) - -afterEach(() => { - if (root) act(() => root?.unmount()) - container?.remove() - root = null - container = null - vi.useRealTimers() -}) - -describe('ModeSwitcher', () => { - it('renders the active mode as a label-only round chip and defaults to Build', () => { - mount() - - const button = trigger() - expect(button.textContent).toBe('Build') - expect(button.getAttribute('aria-label')).toBe('Mode: Build') - expect(button.className).toContain('h-[30px]') - expect(button.className).toContain('rounded-full') - expect(button.className).not.toContain('rounded-lg') - expect(button.className).toContain('hover-hover:bg-[var(--surface-hover)]') - expect(button.querySelector('svg')).toBeNull() - }) - - it('lists every mode and checks the active one', () => { - mount() - openMenu() - - const rows = items() - expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Search', 'Assistant']) - expect(rows[0].querySelector('svg')).not.toBeNull() - expect(rows[1].querySelector('svg')).toBeNull() - expect(rows[2].querySelector('svg')).toBeNull() - }) - - it('writes the chosen mode to the URL and reports the change', async () => { - mount() - openMenu() - await select(1) - - expect(trigger().textContent).toBe('Search') - expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_mode_changed', { - workspace_id: 'workspace-1', - mode: 'search', - }) - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search') - }) - - it('reads the mode from the URL on mount', () => { - mount('?mode=assistant') - - expect(trigger().textContent).toBe('Assistant') - expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') - }) - - it('restores Assistant from a conversation without an explicit URL mode', () => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - navigation.chatId = 'existing-chat' - navigation.requestMode = 'assistant' - mount() - - expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('uses the explicit Assistant selection for the next turn in a Build conversation', () => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - navigation.chatId = 'existing-chat' - navigation.requestMode = 'agent' - mount('?mode=assistant') - - expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') - }) - - it('changes to Build within the restored Assistant conversation', async () => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - navigation.chatId = 'existing-chat' - navigation.requestMode = 'assistant' - mount() - openMenu() - await select(0) - - expect(trigger().getAttribute('aria-label')).toBe('Mode: Build') - expect(mockPush).not.toHaveBeenCalled() - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('build') - }) - - it('clears the composer and search parameters together when leaving Search', async () => { - mount('?mode=search&q=budget&source=upload&updated=7d&resource=report') - openMenu() - await select(0) - - expect(trigger().textContent).toBe('Build') - expect(mockModeChange).toHaveBeenCalledOnce() - expect(mockUrlUpdate).toHaveBeenCalledOnce() - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe( - 'mode=build&resource=report' - ) - expect(mockUrlUpdate.mock.lastCall?.[0].options).toMatchObject({ - history: 'replace', - scroll: false, - }) - expect(mockModeChange.mock.invocationCallOrder[0]).toBeLessThan( - mockUrlUpdate.mock.invocationCallOrder[0] - ) - }) - - it.each([ - ['', 2, 'assistant'], - ['?mode=assistant', 0, 'build'], - ['?mode=assistant', 1, 'search'], - ] as const)( - 'keeps the current chat when selecting a different mode', - async (params, index, target) => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - mount(params) - openMenu() - await select(index) - expect(mockPush).not.toHaveBeenCalled() - expect(mockModeChange).toHaveBeenCalledOnce() - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe(target) - } - ) - - it('does not report re-selecting the active mode', async () => { - mount() - openMenu() - await select(0) - - expect(trigger().textContent).toBe('Build') - expect(mockCaptureEvent).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx deleted file mode 100644 index efc97850b1f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client' - -import { memo } from 'react' -import { - Chip, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuItemLabel, - DropdownMenuTrigger, -} from '@sim/emcn' -import { Check } from '@sim/emcn/icons' -import { useParams } from 'next/navigation' -import { usePostHog } from 'posthog-js/react' -import { captureEvent } from '@/lib/posthog/client' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' -import { - MOTHERSHIP_MODES, - type MothershipMode, -} from '@/app/workspace/[workspaceId]/home/search-params' - -const MODE_LABELS: Record = { - build: 'Build', - search: 'Search', - assistant: 'Assistant', -} - -interface ModeSwitcherProps { - onModeChange?: () => void -} - -/** - * The composer's Build / Search / Assistant switcher: a label-only `Chip` in its `round` - * shape — chip chrome throughout (`--text-body` label, `--surface-hover` on - * hover, no text-color shift), fully round to sit in the toolbar's row of - * round controls — opening a menu that checks the active mode, as - * `ChipDropdown` does. - */ -export const ModeSwitcher = memo(function ModeSwitcher({ onModeChange }: ModeSwitcherProps) { - const { workspaceId } = useParams<{ workspaceId: string }>() - const posthog = usePostHog() - const [mode, setMode] = useMothershipMode() - - const handleSelect = (next: MothershipMode) => { - if (next === mode) return - onModeChange?.() - void setMode(next) - captureEvent(posthog, 'chat_mode_changed', { workspace_id: workspaceId, mode: next }) - } - - return ( - - - - {MODE_LABELS[mode]} - - - - {MOTHERSHIP_MODES.map((option) => ( - handleSelect(option)}> - - {option === mode && } - - ))} - - - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx index bfe0e129bf0..2c6a3944288 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx @@ -22,7 +22,21 @@ const fixtures = vi.hoisted(() => ({ data: [] as { id: string; name: string; parentId: string | null }[], isPending: false, }, - tabs: [], + tabs: [] as Array<{ + terminalId: string + title: string + cwd: string | null + running: string | null + interactive: boolean + active: boolean + }>, + browserTabs: [] as Array<{ + tabId: string + title: string + url: string + loading: boolean + active: boolean + }>, logs: { data: { pages: [{ logs: [{ id: 'log-1', createdAt: '2026-01-01T12:00:00Z', status: 'success' }] }], @@ -63,13 +77,11 @@ vi.mock('@/blocks/integration-matcher', () => ({ { blockType: 'example', name: 'Example integration', icon: () => null }, ], })) -vi.mock('@/stores/browser-session/store', () => ({ useBrowserSessionStore: () => fixtures.tabs })) +vi.mock('@/stores/browser-session/store', () => ({ + useBrowserSessionStore: () => fixtures.browserTabs, +})) vi.mock('@/stores/copilot-terminal/store', () => ({ useCopilotTerminalStore: () => fixtures.tabs })) -import { - BROWSER_SESSION_RESOURCE_ID, - TERMINAL_SESSION_RESOURCE_ID, -} from '@/lib/copilot/resources/types' import { setDesktopPreferencesSnapshot } from '@/lib/desktop' import { mapResourceToContext, @@ -146,6 +158,8 @@ describe('PlusMenuDropdown desktop resources', () => { } fixtures.browserAvailable.mockReturnValue(true) fixtures.terminalAvailable.mockReturnValue(true) + fixtures.browserTabs.length = 0 + fixtures.tabs.length = 0 setDesktopPreferencesSnapshot(PREFERENCES) Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, @@ -168,6 +182,21 @@ describe('PlusMenuDropdown desktop resources', () => { }) it('keeps shared categories in the same order in browse and mention modes', () => { + fixtures.tabs.push({ + terminalId: '9', + title: 'sim', + cwd: '/code/sim', + running: null, + interactive: false, + active: true, + }) + fixtures.browserTabs.push({ + tabId: '7', + title: 'Sim Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }) const { ref } = openMenu() const browseOrder = menuItems().map((item) => item.textContent) expect(browseOrder).toEqual([ @@ -186,36 +215,73 @@ describe('PlusMenuDropdown desktop resources', () => { expect(headings).toEqual(['Integrations', ...browseOrder]) }) - it.each([false, true])('selects the same whole Browser in mention=%s mode', (mention) => { - const { onResourceSelect } = openMenu(mention) - selectItem('Browser') + it('lists a live page under the Browser category in browse mode', () => { + fixtures.browserTabs.push({ + tabId: '7', + title: 'Sim Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }) + openMenu() + expect(menuItems().map((item) => item.textContent)).toContain('Browser') + }) + + it('offers each live browser tab as itself in mention mode', () => { + fixtures.browserTabs.push({ + tabId: '7', + title: 'Sim Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }) + const { onResourceSelect } = openMenu(true) + expect(menuItems().map((item) => item.textContent)).not.toContain('Browser') + selectItem('Sim Docs') expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({ type: 'browser', - id: BROWSER_SESSION_RESOURCE_ID, - title: 'Browser', + id: '7', + title: 'Sim Docs', }) expect(mapResourceToContext(onResourceSelect.mock.calls[0][0])).toEqual({ kind: 'browser_tab', - tabId: BROWSER_SESSION_RESOURCE_ID, - label: 'Browser', + tabId: '7', + label: 'Sim Docs', }) }) it.each([false, true])( - 'updates mounted desktop rows when preferences change in mention=%s mode', + 'offers no desktop rows without a live page or shell in mention=%s mode', (mention) => { openMenu(mention) - expect(menuItems().map((item) => item.textContent)).toContain('Browser') - - fixtures.browserAvailable.mockReturnValue(false) - act(() => setDesktopPreferencesSnapshot({ ...PREFERENCES, browserEnabled: false })) - expect(menuItems().map((item) => item.textContent)).not.toContain('Browser') - expect(menuItems().map((item) => item.textContent)).toContain('Terminal') + const names = menuItems().map((item) => item.textContent) + expect(names).not.toContain('Browser') + expect(names).not.toContain('Terminal') } ) - it('finds Browser through plus-menu search and selects it with Enter', () => { + it('lists a live shell under the Terminal category in browse mode', () => { + fixtures.tabs.push({ + terminalId: '9', + title: 'sim', + cwd: '/code/sim', + running: null, + interactive: false, + active: true, + }) + openMenu() + expect(menuItems().map((item) => item.textContent)).toContain('Terminal') + }) + + it('finds a browser tab through plus-menu search by its family and selects it with Enter', () => { + fixtures.browserTabs.push({ + tabId: '7', + title: 'Sim Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }) const { onResourceSelect } = openMenu() const search = document.querySelector( 'input[placeholder="Search resources..."]' @@ -226,24 +292,44 @@ describe('PlusMenuDropdown desktop resources', () => { valueSetter.call(search, 'browser') search.dispatchEvent(new Event('input', { bubbles: true })) }) - expect(menuItems().map((item) => item.textContent)).toEqual(['Browser']) + expect(menuItems().map((item) => item.textContent)).toEqual(['Sim Docs']) act(() => search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({ type: 'browser', - id: BROWSER_SESSION_RESOURCE_ID, - title: 'Browser', + id: '7', + title: 'Sim Docs', }) }) - it.each([false, true])('keeps unavailable Browser hidden in mention=%s mode', (mention) => { + it('keeps unavailable Browser hidden while offering live shells in mention mode', () => { fixtures.browserAvailable.mockReturnValue(false) - const { onResourceSelect } = openMenu(mention) - expect(menuItems().some((item) => item.textContent === 'Browser')).toBe(false) - selectItem('Terminal') + fixtures.browserTabs.push({ + tabId: '7', + title: 'Sim Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }) + fixtures.tabs.push({ + terminalId: '9', + title: 'sim', + cwd: '/code/sim', + running: null, + interactive: false, + active: true, + }) + const { onResourceSelect } = openMenu(true) + expect(menuItems().some((item) => item.textContent === 'Sim Docs')).toBe(false) + selectItem('sim') expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({ type: 'terminal', - id: TERMINAL_SESSION_RESOURCE_ID, - title: 'Terminal', + id: 'terminal:9', + title: 'sim', + }) + expect(mapResourceToContext(onResourceSelect.mock.calls[0][0])).toEqual({ + kind: 'terminal_tab', + terminalId: '9', + label: 'sim', }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index e3b23f6c662..665af481bdb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -24,13 +24,15 @@ import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/componen import { buildMentionPreview, resourceMentionMatches, - withDesktopTabMentions, + withBrowserTabMentions, withFolderMentions, + withTerminalTabMentions, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands' import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' @@ -122,13 +124,26 @@ export const PlusMenuDropdown = React.memo( setOpen(false) }, []) + const settledCommands = useSettledTerminalCommands(terminalTabs) const visibleResources = useMemo(() => { - const resources = withFolderMentions(availableResources, structureFolders) - if (isMention) { - return withDesktopTabMentions(resources, browserTabs, terminalTabs) - } + const resources = withTerminalTabMentions( + withBrowserTabMentions( + withFolderMentions(availableResources, structureFolders), + browserTabs + ), + terminalTabs, + settledCommands + ) + if (isMention) return resources return resources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)) - }, [availableResources, structureFolders, browserTabs, isMention, terminalTabs]) + }, [ + availableResources, + structureFolders, + browserTabs, + isMention, + settledCommands, + terminalTabs, + ]) const treeSections = useResourceTreeSections({ groups: availableResources, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts index 90343353cf3..f83880f0df7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts @@ -1,76 +1,84 @@ import { describe, expect, it } from 'vitest' -import { - BROWSER_SESSION_RESOURCE_ID, - TERMINAL_SESSION_RESOURCE_ID, -} from '@/lib/copilot/resources/types' import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' import { byResourceMenuOrder } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { buildMentionPreview, resourceMentionMatches, - withDesktopTabMentions, + withBrowserTabMentions, withFolderMentions, + withTerminalTabMentions, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' const groups = [ { type: 'workflow' as const, items: [{ id: 'wf-1', name: 'Deploy' }] }, { type: 'browser' as const, - items: [{ id: BROWSER_SESSION_RESOURCE_ID, name: 'Browser' }], + items: [{ id: 'browser', name: 'Browser' }], }, { type: 'terminal' as const, - items: [{ id: TERMINAL_SESSION_RESOURCE_ID, name: 'Terminal' }], + items: [{ id: 'terminal', name: 'Terminal' }], }, ] -describe('withDesktopTabMentions', () => { - it('keeps Browser and Terminal as flat resource mentions with no live tabs', () => { - const result = withDesktopTabMentions(groups, [], []) +describe('withBrowserTabMentions', () => { + it('drops the Browser launcher when no page is open', () => { + const result = withBrowserTabMentions(groups, []) - expect(result.find((group) => group.type === 'browser')?.items).toEqual([ - expect.objectContaining({ - id: BROWSER_SESSION_RESOURCE_ID, - name: 'Browser', - mentionLevel: 'resource', - }), + expect(result.find((group) => group.type === 'browser')?.items).toEqual([]) + expect(result.find((group) => group.type === 'workflow')).toBe(groups[0]) + }) + + it('offers every live page as its own Browser mention', () => { + const result = withBrowserTabMentions(groups, [ + { + tabId: 'browser-1', + title: 'Sim Docs', + url: 'https://docs.sim.ai', + loading: false, + active: true, + }, + { + tabId: 'browser-2', + title: '', + url: 'https://github.com/simstudioai/sim', + loading: false, + active: false, + }, ]) - expect(result.find((group) => group.type === 'terminal')?.items).toEqual([ - expect.objectContaining({ - id: TERMINAL_SESSION_RESOURCE_ID, - name: 'Terminal', - mentionLevel: 'resource', - }), + + expect(result.find((group) => group.type === 'browser')?.items).toEqual([ + { id: 'browser-1', name: 'Sim Docs', mentionFamily: 'Browser' }, + { id: 'browser-2', name: 'github.com', mentionFamily: 'Browser' }, ]) }) - it('offers the whole resources first and every live tab after them', () => { - const result = withDesktopTabMentions( + it('keeps specific tabs discoverable by either their title or resource family', () => { + const tab = { id: 'browser-1', name: 'Sim Docs', mentionFamily: 'Browser' } + + expect(resourceMentionMatches(tab, 'docs')).toBe(true) + expect(resourceMentionMatches(tab, 'browser')).toBe(true) + expect(resourceMentionMatches(tab, 'terminal')).toBe(false) + }) +}) + +describe('withTerminalTabMentions', () => { + it('drops the Terminal launcher when no shell is open', () => { + const result = withTerminalTabMentions(groups, [], new Set()) + + expect(result.find((group) => group.type === 'terminal')?.items).toEqual([]) + expect(result.find((group) => group.type === 'workflow')).toBe(groups[0]) + }) + + it('offers every live shell as its own Terminal mention, named like the strip', () => { + const result = withTerminalTabMentions( groups, - [ - { - tabId: 'browser-1', - title: 'Sim Docs', - url: 'https://docs.sim.ai', - loading: false, - active: true, - pinned: false, - }, - { - tabId: 'browser-2', - title: '', - url: 'https://github.com/simstudioai/sim', - loading: false, - active: false, - pinned: false, - }, - ], [ { terminalId: 'terminal-1', title: 'sim', cwd: '/code/sim', - running: null, + running: 'bun run build', interactive: false, active: true, }, @@ -82,33 +90,15 @@ describe('withDesktopTabMentions', () => { interactive: false, active: false, }, - ] + ], + new Set(['terminal-1']) ) - expect(result.find((group) => group.type === 'browser')?.items).toMatchObject([ - { id: BROWSER_SESSION_RESOURCE_ID, name: 'Browser', mentionLevel: 'resource' }, - { id: 'browser-1', name: 'Sim Docs', mentionLevel: 'tab' }, - { id: 'browser-2', name: 'github.com', mentionLevel: 'tab' }, - ]) - expect(result.find((group) => group.type === 'terminal')?.items).toMatchObject([ - { id: TERMINAL_SESSION_RESOURCE_ID, name: 'Terminal', mentionLevel: 'resource' }, - { id: 'terminal-1', name: 'sim 1', mentionLevel: 'tab' }, - { id: 'terminal-2', name: 'sim 2', mentionLevel: 'tab' }, + expect(result.find((group) => group.type === 'terminal')?.items).toEqual([ + { id: 'terminal:terminal-1', name: 'bun run build', mentionFamily: 'Terminal' }, + { id: 'terminal:terminal-2', name: 'sim', mentionFamily: 'Terminal' }, ]) }) - - it('keeps specific tabs discoverable by either their title or resource family', () => { - const tab = { - id: 'browser-1', - name: 'Sim Docs', - mentionFamily: 'Browser', - mentionLevel: 'tab', - } - - expect(resourceMentionMatches(tab, 'docs')).toBe(true) - expect(resourceMentionMatches(tab, 'browser')).toBe(true) - expect(resourceMentionMatches(tab, 'terminal')).toBe(false) - }) }) describe('buildMentionPreview', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts index 13fcd892752..585f805f719 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts @@ -1,11 +1,9 @@ import type { BrowserTabState } from '@sim/browser-protocol' import type { TerminalTabState } from '@sim/terminal-protocol' -import { - BROWSER_SESSION_RESOURCE_ID, - TERMINAL_SESSION_RESOURCE_ID, -} from '@/lib/copilot/resources/types' +import { browserTabTitle } from '@/lib/browser-agent/tab-label' +import { terminalResourceId } from '@/lib/terminal/resource-id' +import { terminalTabTitle } from '@/lib/terminal/tab-label' import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' -import { browserTabTitle } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' export interface ResourceMentionGroup { @@ -35,8 +33,6 @@ export function withFolderMentions( ) } -export type ResourceMentionLevel = 'resource' | 'tab' - /** A family query such as "browser" keeps that resource's live tabs visible. */ export function resourceMentionMatches(item: AvailableItem, query: string): boolean { const normalized = query.toLowerCase().trim() @@ -61,58 +57,56 @@ function uniqueTabNames(tabs: readonly T[], nameOf: (tab: T) => string): stri }) } -function resourceItem(id: string, name: string, existing?: AvailableItem): AvailableItem { - return { - ...existing, - id, - name, - mentionFamily: name, - mentionLevel: 'resource' satisfies ResourceMentionLevel, - } -} - -/** Adds live inner tabs after each always-present desktop resource mention. */ -export function withDesktopTabMentions( +/** + * Replaces the Browser launcher row with the live pages, which are the only + * browser things that can be attached or mentioned. With no page open the + * family disappears from the menu. + */ +export function withBrowserTabMentions( groups: readonly ResourceMentionGroup[], - browserTabs: readonly BrowserTabState[], - terminalTabs: readonly TerminalTabState[] + browserTabs: readonly BrowserTabState[] ): ResourceMentionGroup[] { const browserNames = uniqueTabNames(browserTabs, browserTabTitle) - const terminalNames = uniqueTabNames(terminalTabs, (tab) => tab.title.trim() || 'Terminal') - - return groups.map((group) => { - if (group.type === 'browser') { - const existing = group.items.find((item) => item.id === BROWSER_SESSION_RESOURCE_ID) - return { - ...group, - items: [ - resourceItem(BROWSER_SESSION_RESOURCE_ID, 'Browser', existing), - ...browserTabs.map((tab, index) => ({ + return groups.map((group) => + group.type === 'browser' + ? { + ...group, + items: browserTabs.map((tab, index) => ({ id: tab.tabId, name: browserNames[index], mentionFamily: 'Browser', - mentionLevel: 'tab' satisfies ResourceMentionLevel, })), - ], - } - } - if (group.type === 'terminal') { - const existing = group.items.find((item) => item.id === TERMINAL_SESSION_RESOURCE_ID) - return { - ...group, - items: [ - resourceItem(TERMINAL_SESSION_RESOURCE_ID, 'Terminal', existing), - ...terminalTabs.map((tab, index) => ({ - id: tab.terminalId, + } + : group + ) +} + +/** + * Replaces the Terminal launcher row with the live shells, which are the only + * terminal things that can be attached or mentioned. With no shell open the + * family disappears from the menu. A shell is named after its settled + * foreground program, else its directory; the strip settles the same way. + */ +export function withTerminalTabMentions( + groups: readonly ResourceMentionGroup[], + terminalTabs: readonly TerminalTabState[], + settledCommands: ReadonlySet +): ResourceMentionGroup[] { + const terminalNames = uniqueTabNames(terminalTabs, (tab) => + terminalTabTitle(tab, settledCommands) + ) + return groups.map((group) => + group.type === 'terminal' + ? { + ...group, + items: terminalTabs.map((tab, index) => ({ + id: terminalResourceId(tab.terminalId), name: terminalNames[index], mentionFamily: 'Terminal', - mentionLevel: 'tab' satisfies ResourceMentionLevel, })), - ], - } - } - return group - }) + } + : group + ) } /** One row of the `@` list: an item plus the family it came from. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts index 47e0216319f..8eaab2d26f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts @@ -10,15 +10,12 @@ function resource(partial: Partial & Pick { - it('turns the singleton panels into whole-resource pointers', () => { - expect( - mapResourceToContext(resource({ type: 'browser', id: 'browser-session', title: 'Browser' })) - ).toEqual({ kind: 'browser_tab', tabId: 'browser-session', label: 'Browser' }) - expect( - mapResourceToContext( - resource({ type: 'terminal', id: 'terminal-session', title: 'Terminal' }) - ) - ).toEqual({ kind: 'terminal_tab', terminalId: 'terminal-session', label: 'Terminal' }) + it('turns a terminal tab into a pointer at that shell', () => { + expect(mapResourceToContext(resource({ type: 'terminal', id: '3', title: 'sim' }))).toEqual({ + kind: 'terminal_tab', + terminalId: '3', + label: 'sim', + }) }) it('turns a dragged browser tab into a pointer at that tab', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx index 49262bb05fd..2db257e887f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx @@ -1,19 +1,16 @@ /** * @vitest-environment jsdom */ -import { act, createRef, useRef } from 'react' -import { useQueryState } from 'nuqs' +import { act, createRef } from 'react' import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PromptEditorInstance } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types' -const { mockSubmit, mockResetTranscript, mockMemberAccessAvailable } = vi.hoisted(() => ({ +const { mockSubmit, mockResetTranscript } = vi.hoisted(() => ({ mockSubmit: vi.fn(), mockResetTranscript: vi.fn(), - /** Search mode exists only where per-member access is on; these tests are that workspace. */ - mockMemberAccessAvailable: vi.fn(() => true), })) vi.mock('next/navigation', () => ({ @@ -23,9 +20,6 @@ vi.mock('next/navigation', () => ({ })) vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() })) -vi.mock('@/hooks/use-member-access', () => ({ - useMemberAccessAvailable: () => mockMemberAccessAvailable(), -})) vi.mock('@/hooks/use-settings-navigation', () => ({ useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), })) @@ -68,12 +62,8 @@ vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', a const { usePromptEditor } = await import( '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' ) - const { ModeSwitcher } = await import( - '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher' - ) return { usePromptEditor, - ModeSwitcher, PromptEditor: ({ editor, placeholder, @@ -105,8 +95,6 @@ import { UserInput, type UserInputHandle, } from '@/app/workspace/[workspaceId]/home/components/user-input/user-input' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' -import { searchQueryParam } from '@/app/workspace/[workspaceId]/home/search-params' const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() const QUEUED_MESSAGE: QueuedMessage = { @@ -120,34 +108,26 @@ const QUEUED_MESSAGE: QueuedMessage = { let root: Root | null = null let container: HTMLDivElement | null = null -function mount(requestMode?: QueuedMessage['requestMode']) { +function mount() { const inputRef = createRef() function Composer() { - const [mode, setMode] = useMothershipMode() - const [query] = useQueryState(searchQueryParam.key, searchQueryParam.parser) - const modes = useRef([]) - modes.current.push(`${mode}:${query ?? ''}`) return ( <> - {modes.current.join('|')} ) @@ -187,22 +167,6 @@ async function clickButton(label: string) { }) } -async function selectMode(label: string) { - const trigger = container?.querySelector('[aria-label="Mode: Search"]') - if (!trigger) throw new Error('Mode switcher did not render') - act(() => { - trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) - }) - const item = Array.from(document.querySelectorAll('[role="menuitem"]')).find( - (candidate) => candidate.textContent === label - ) - if (!item) throw new Error(`Mode ${label} did not render`) - await act(async () => { - item.click() - await vi.advanceTimersByTimeAsync(1) - }) -} - beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -219,52 +183,29 @@ afterEach(() => { vi.useRealTimers() }) -describe('search composer transitions', () => { - it.each(['Build', 'Assistant'])('clears the query when the menu selects %s', async (mode) => { +describe('workspace composer', () => { + it('keeps workspace controls and ignores legacy search-mode URLs', () => { mount() - expect(textarea().value).toBe('budget') - expect(textarea().placeholder).toBe('Search your documents…') - - await selectMode(mode) - - expect(textarea().value).toBe('') - expect(textarea().placeholder).toBe( - mode === 'Assistant' ? 'Ask about your documents or take action…' : 'Ask Sim to ' - ) - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.has('q')).toBe(false) - expect(mockSubmit).not.toHaveBeenCalled() - }) - - it.each([undefined, 'assistant'] as const)( - 'retains queued content and files after restoring request mode %s', - async (requestMode) => { - mount(requestMode) - - await clickButton('Edit queued') - - expect(textarea().value).toBe(QUEUED_MESSAGE.content) - expect(container?.querySelector('output')?.textContent).not.toContain('build:budget') - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe( - requestMode === 'assistant' - ? 'mode=assistant&resource=report' - : 'mode=build&resource=report' - ) - await clickButton('Send') - expect(mockSubmit).toHaveBeenCalledWith( - QUEUED_MESSAGE.content, - requestMode === 'assistant' ? undefined : QUEUED_MESSAGE.fileAttachments, - undefined - ) + expect(textarea().value).toBe('Initial draft') + expect(textarea().placeholder).toBe('Ask Sim to ') + expect(container?.querySelector('[aria-label^="Mode:"]')).toBeNull() + for (const label of ['Add resources', 'Attach file', 'Skills']) { + expect(container?.querySelector(`[aria-label="${label}"]`)).not.toBeNull() } - ) - - it('starts a clean composer when changing modes', async () => { - const inputRef = mount() - act(() => inputRef.current?.loadQueuedMessage({ ...QUEUED_MESSAGE, content: 'budget' })) + expect(mockUrlUpdate).not.toHaveBeenCalled() + }) - await selectMode('Build') + it('retains queued content and attachments when editing, then clears after sending', async () => { + mount() + await clickButton('Edit queued') + expect(textarea().value).toBe(QUEUED_MESSAGE.content) await clickButton('Send') - - expect(mockSubmit).toHaveBeenCalledWith('', undefined, undefined) + expect(mockSubmit).toHaveBeenCalledWith( + QUEUED_MESSAGE.content, + QUEUED_MESSAGE.fileAttachments, + undefined + ) + expect(textarea().value).toBe('') + expect(mockResetTranscript).toHaveBeenCalled() }) }) 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 a3ddd724773..036b6ecc228 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 @@ -16,7 +16,6 @@ import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' -import { getDesktopBridge } from '@/lib/desktop' import { MOTHERSHIP_ADD_CONTEXT_EVENT } from '@/lib/mothership/events' import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -26,13 +25,11 @@ import { DropOverlay, MicButton, MicrophonePermissionHelp, - ModeSwitcher, PromptEditor, SendButton, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' import type { FileAttachmentForApi, MothershipResource, @@ -43,7 +40,7 @@ import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/ import { mentionifyIntegrations } from '@/blocks/integration-matcher' import { useChatInputFocus } from '@/hooks/use-chat-input-focus' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { type SpeechToTextError, useSpeechToText } from '@/hooks/use-speech-to-text' +import { useVoiceInput } from '@/hooks/use-voice-input' import { type DraftPayload, useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import type { ChatContext } from '@/stores/panel' @@ -63,21 +60,6 @@ interface UserInputProps { onStopGeneration: () => void isInitialView?: boolean onSendQueuedHead?: () => void - /** - * Whether the composer offers Search mode. Only the Home composer answers a - * search with documents; the workflow copilot always talks to the agent, so - * it must not show a mode it cannot honour. - */ - canSearch?: boolean - /** - * Whether the text is cleared once submitted. A search keeps its query in - * the box, the way a search bar does, so it can be read and refined against - * the results; a message to the agent clears, since it now lives in the - * transcript. Defaults to clearing. - */ - clearOnSubmit?: boolean - /** Called when the text becomes empty after having had content, such as a search being cleared. */ - onCleared?: () => void onEditQueuedTail?: () => void } @@ -89,8 +71,6 @@ export interface UserInputHandle { * names chip with brand icons. Focuses the input and places the caret at the * end. Does NOT submit. Safe to call with the same text twice in a row. */ populatePrompt: (text: string) => void - /** Empties the composer and its draft, as a send does; for a question handed to the agent from outside the box. */ - clear: () => void } /** @@ -108,22 +88,12 @@ const UserInputImpl = forwardRef(function UserI isInitialView = true, onSendQueuedHead, onEditQueuedTail, - canSearch = false, - clearOnSubmit = true, - onCleared, }, ref ) { const { workspaceId } = useParams<{ workspaceId: string }>() const { navigateToSettings } = useSettingsNavigation() const { userId, onContextAdd, onContextRemove } = useChatSurface() - const [mode] = useMothershipMode() - const isSearch = canSearch && mode === 'search' - const contextsEnabled = !canSearch || mode === 'build' - const contextsEnabledRef = useRef(contextsEnabled) - contextsEnabledRef.current = contextsEnabled - const [microphonePermissionHelpOpen, setMicrophonePermissionHelpOpen] = useState(false) - const [initialValue] = useState(() => { if (defaultValue) return defaultValue if (!draftScopeKey) return '' @@ -136,17 +106,16 @@ const UserInputImpl = forwardRef(function UserI const files = useFileAttachments({ userId, workspaceId, - disabled: !contextsEnabled, isLoading: isSending, }) - const hasFiles = contextsEnabled && files.attachedFiles.some((f) => !f.uploading && f.key) - const hasUploadingFiles = contextsEnabled && files.attachedFiles.some((f) => f.uploading) + const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key) + const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading) const filesRef = useRef(files) filesRef.current = files const handlePasteFiles = useCallback((pasted: FileList) => { - if (contextsEnabledRef.current) filesRef.current.processFiles(pasted) + filesRef.current.processFiles(pasted) }, []) const editor = usePromptEditor({ @@ -154,7 +123,6 @@ const UserInputImpl = forwardRef(function UserI initialValue, onContextAdd, onPasteFiles: handlePasteFiles, - contextsEnabled, }) const editorRef = useRef(editor) editorRef.current = editor @@ -169,7 +137,7 @@ const UserInputImpl = forwardRef(function UserI */ useEffect(() => { const handleAddContext = (event: Event) => { - if (contextsEnabledRef.current) handleMothershipAddContextEvent(event, editorRef.current) + handleMothershipAddContextEvent(event, editorRef.current) } window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handleAddContext) @@ -178,8 +146,6 @@ const UserInputImpl = forwardRef(function UserI const draftScopeKeyRef = useRef(draftScopeKey) draftScopeKeyRef.current = draftScopeKey - const clearOnSubmitRef = useRef(clearOnSubmit) - clearOnSubmitRef.current = clearOnSubmit const hasRestoredDraftRef = useRef(false) useEffect(() => { @@ -214,8 +180,8 @@ const UserInputImpl = forwardRef(function UserI useMothershipDraftsStore.getState().clearDraft(draftScopeKey) return } - if (contextsEnabled && restoredContexts) editor.setContexts(restoredContexts) - if (contextsEnabled && restoredFiles) files.restoreAttachedFiles(restoredFiles) + if (restoredContexts) editor.setContexts(restoredContexts) + if (restoredFiles) files.restoreAttachedFiles(restoredFiles) if (caretText !== null) { const textarea = textareaRef.current if (textarea) { @@ -225,15 +191,6 @@ const UserInputImpl = forwardRef(function UserI } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore - const onClearedRef = useRef(onCleared) - onClearedRef.current = onCleared - const hadTextRef = useRef(false) - useEffect(() => { - const hasText = editor.value.trim().length > 0 - if (hadTextRef.current && !hasText) onClearedRef.current?.() - hadTextRef.current = hasText - }, [editor.value]) - const isFirstSaveRef = useRef(true) const draftSaveTimerRef = useRef(null) const pendingDraftRef = useRef<{ key: string; payload: DraftPayload } | null>(null) @@ -323,8 +280,7 @@ const UserInputImpl = forwardRef(function UserI * landing prompt panel as well as curated CTAs. Curated producers opt their * bare names in at the store seam (`storeCuratedPrompt`), so prose seeded here * is never bare-chipped (the scunthorpe constraint). - * An empty seed must not erase a queued message loaded in the same event - * that clears the search URL. The mode menu clears its query explicitly. + * An empty seed must not erase a queued message loaded for editing. */ useEffect(() => { if (defaultValue === prevDefaultValueRef.current) return @@ -332,14 +288,6 @@ const UserInputImpl = forwardRef(function UserI if (defaultValue) editorRef.current.setValue(defaultValue) }, [defaultValue]) - const sttPrefixRef = useRef('') - - function handleTranscript(text: string) { - const prefix = sttPrefixRef.current - const newVal = prefix ? `${prefix} ${text}` : text - editorRef.current.setValue(newVal) - } - function handleUsageLimitExceeded(message?: string, isMemberLimit?: boolean) { // A per-member cap can only be raised by an org admin, so don't offer Upgrade // (the member can't act on it) — the message already tells them to ask an admin. @@ -356,59 +304,21 @@ const UserInputImpl = forwardRef(function UserI ) } - function handleSpeechError(error: SpeechToTextError) { - if (error === 'microphone-blocked') { - const desktopBridge = getDesktopBridge() - if (desktopBridge) { - const { openMicrophoneSettings } = desktopBridge - toast.error( - 'Microphone access is blocked. Allow Sim to use the microphone in your system privacy settings.', - openMicrophoneSettings - ? { - action: { - label: 'Open Settings', - onClick: () => void openMicrophoneSettings(), - }, - } - : undefined - ) - } else { - toast.error('Microphone access is blocked. Allow it for this site and try again.', { - action: { - label: 'Show steps', - onClick: () => setMicrophonePermissionHelpOpen(true), - }, - }) - } - return - } - if (error === 'microphone-unavailable') { - toast.error('No microphone found. Connect one and try again.') - return - } - toast.error('Could not start voice input. Try again.') - } - const { audioLevelsRef, isListening, isSupported: isSttSupported, - toggleListening: rawToggle, + toggleListening, resetTranscript, - } = useSpeechToText({ - onTranscript: handleTranscript, - onUsageLimitExceeded: handleUsageLimitExceeded, - onError: handleSpeechError, + permissionHelpOpen, + setPermissionHelpOpen, + } = useVoiceInput({ workspaceId, + getValue: () => editorRef.current.getValue(), + onChange: (value) => editorRef.current.setValue(value), + onUsageLimitExceeded: handleUsageLimitExceeded, }) - const toggleListening = useCallback(() => { - if (!isListening) { - sttPrefixRef.current = editorRef.current.getValue() - } - rawToggle() - }, [isListening, rawToggle]) - const onSendQueuedHeadRef = useRef(onSendQueuedHead) onSendQueuedHeadRef.current = onSendQueuedHead const onEditQueuedTailRef = useRef(onEditQueuedTail) @@ -438,7 +348,6 @@ const UserInputImpl = forwardRef(function UserI currentEditor.setContexts(msg.contexts ?? []) currentEditor.focusAtEnd() }, - clear: clearComposer, populatePrompt: (text: string) => { // `text` is a curated prompt, so opt its bare integration names into // `@`-mention form before chipification (the auto-mention pipeline only @@ -453,7 +362,7 @@ const UserInputImpl = forwardRef(function UserI ) const handleFileSelectStable = useCallback(() => { - if (contextsEnabledRef.current) filesRef.current.handleFileSelect() + filesRef.current.handleFileSelect() }, []) const handleFileClick = useCallback((file: AttachedFile) => { @@ -479,10 +388,6 @@ const UserInputImpl = forwardRef(function UserI const handleContainerDrop = useCallback( (e: React.DragEvent) => { - if (!contextsEnabledRef.current) { - e.preventDefault() - return - } const resourcesJson = e.dataTransfer.getData(SIM_RESOURCES_DRAG_TYPE) if (resourcesJson) { e.preventDefault() @@ -542,8 +447,8 @@ const UserInputImpl = forwardRef(function UserI }, [isSending, textareaRef]) /** - * Menu rows are excluded alongside buttons: the mode switcher's items are - * portaled, so their clicks still bubble here through the React tree. + * Portaled dialogs and menus still bubble clicks through the React tree; + * they must keep focus rather than returning it to the composer. */ const handleContainerClick = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, [role="dialog"], [role="menu"]')) return @@ -553,7 +458,6 @@ const UserInputImpl = forwardRef(function UserI /** Empties the text, chips, attachments, transcript, and the saved draft in one step. */ const clearComposer = useCallback(() => { editorRef.current.clear() - sttPrefixRef.current = '' if (draftSaveTimerRef.current !== null) { window.clearTimeout(draftSaveTimerRef.current) draftSaveTimerRef.current = null @@ -572,9 +476,7 @@ const UserInputImpl = forwardRef(function UserI const currentFiles = filesRef.current const currentEditor = editorRef.current - const fileAttachmentsForApi: FileAttachmentForApi[] = ( - contextsEnabledRef.current ? currentFiles.attachedFiles : [] - ) + const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles .filter((f) => !f.uploading && f.key) .map((f) => ({ id: f.id, @@ -594,12 +496,7 @@ const UserInputImpl = forwardRef(function UserI fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, activeContexts.length > 0 ? activeContexts : undefined ) - /** - * A composer that keeps its text (Search mode) keeps its attachments and - * chips too: the search took the query alone, and the person may hand the - * rest to the agent next. - */ - if (clearOnSubmitRef.current) clearComposer() + clearComposer() }, [onSubmit, clearComposer]) /** @@ -662,27 +559,17 @@ const UserInputImpl = forwardRef(function UserI onDragOver={handleContainerDragOver} onDrop={handleContainerDrop} > - {!isSearch && mode !== 'assistant' && ( - - )} + - {contextsEnabled && ( - - )} + (function UserI
- {contextsEnabled && ( - <> - - - - - Add resources - - - - - - Attach file - - - - - - Skills - - - )} + + + + + Add resources + + + + + + Attach file + + + + + + Skills +
- {canSearch && } {isSttSupported && ( (function UserI className='hidden' accept={MOTHERSHIP_ACCEPT_ATTRIBUTE} multiple - disabled={!contextsEnabled} /> {files.isDragging && } - +
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 68b925f26c0..c169af084e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -17,11 +17,10 @@ import { PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { useQueryState, useQueryStates } from 'nuqs' +import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' -import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search' import { LandingPromptStorage, type LandingWorkflowSeed, @@ -35,35 +34,20 @@ import { } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' -/** - * Imported from its own folder, not the components barrel: the workflow copilot - * panel imports that barrel for the chat pieces, and a barrel edge to this - * component would drag the Sim Search connector catalog — every connector - * meta — into the workflow editor's graph. See sim-imports.md, "Code-splitting - * through barrels". - */ -import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results' import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' +import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources' +import { useTerminalTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources' import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { resolveResourceEventPresentation, resolveResourceSelectionUpdate, } from '@/app/workspace/[workspaceId]/home/resource-view-policy' -import { - CLEARED_SEARCH_FILTERS, - type MothershipMode, - resourceParam, - resourceUrlKeys, - searchFilterParsers, - searchQueryParam, -} from '@/app/workspace/[workspaceId]/home/search-params' +import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -85,7 +69,6 @@ import type { FileAttachmentForApi, MothershipResource, MothershipResourceType, - QueuedMessage, WorkspaceResourceRef, } from './types' @@ -165,27 +148,9 @@ export function Home({ chatId, userName, userId }: HomeProps) { const posthogRef = useRef(posthog) posthogRef.current = posthog const [initialPrompt, setInitialPrompt] = useState('') - /** The search query lives in the URL so a search is a shareable link; null between searches. */ - const [searchQueryValue, setSearchQueryParam] = useQueryState(searchQueryParam.key, { - ...searchQueryParam.parser, - ...resourceUrlKeys, - }) - const searchQuery = searchQueryValue ?? '' - const [, setSearchFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys) - /** A new or cleared query starts from unfiltered results. */ - const setSearchQuery = useCallback( - (query: string) => { - void setSearchQueryParam(query || null) - void setSearchFilters(CLEARED_SEARCH_FILTERS) - }, - [setSearchQueryParam, setSearchFilters] - ) - const memberAccessAvailable = useMemberAccessAvailable() - const [composerMode, setComposerMode] = useMothershipMode() const hasCheckedLandingStorageRef = useRef(false) const initialViewInputRef = useRef(null) const initialViewUserInputRef = useRef(null) - const chatViewUserInputRef = useRef(null) const [isInputEntering, setIsInputEntering] = useState(false) @@ -369,6 +334,25 @@ export function Home({ chatId, userName, userId }: HomeProps) { [setActiveResourceId, clearResourceActivity] ) + const desktopTabResourceCallbacks = { + addResource, + removeResource, + selectResource: selectResourceFromUser, + onResourceEvent: handleResourceEvent, + } + useBrowserTabResources({ + scopeId: desktopScopeId, + resources, + activeResourceId, + ...desktopTabResourceCallbacks, + }) + useTerminalTabResources({ + scopeId: desktopScopeId, + resources, + activeResourceId, + ...desktopTabResourceCallbacks, + }) + const addResourceFromUser = useCallback( (resource: MothershipResource) => { resourceCollapseOwnedByUserRef.current = false @@ -459,35 +443,10 @@ export function Home({ chatId, userName, userId }: HomeProps) { }, [workspaceId, getCurrentRequestId, stopGeneration]) const handleSubmit = useCallback( - async ( - text: string, - fileAttachments?: FileAttachmentForApi[], - contexts?: ChatContext[], - modeOverride?: MothershipMode, - assistantSearch?: WorkspaceSearchFilters - ) => { + async (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return - /** - * Search lists documents, not a turn of the agent, and only a query can - * be searched: attachments alone have nothing to search for. Assistant - * makes the query a turn of the agent grounded in the sources. - * - * The override skips `useMothershipMode`, so the gate is applied again - * where the mode is consumed: both modes answer from the workspace's - * indexed sources, and neither is offered where those do not exist. - */ - const requestedMode = modeOverride ?? composerMode - const mode = requestedMode !== 'build' && !memberAccessAvailable ? 'build' : requestedMode - const answering = mode === 'assistant' - if (mode === 'search') { - /** A search sends nothing, so an edit in progress is released rather than left waiting. */ - if (editingQueuedId) cancelQueueEdit() - if (trimmed) setSearchQuery(trimmed) - return - } - captureEvent(posthogRef.current, 'task_message_sent', { workspace_id: workspaceId, has_attachments: !!(fileAttachments && fileAttachments.length > 0), @@ -500,65 +459,11 @@ export function Home({ chatId, userName, userId }: HomeProps) { } prepareResourceViewForAgentTurn() - sendMessage( - trimmed || 'Analyze the attached file(s).', - fileAttachments, - contexts, - answering ? { requestMode: 'assistant', assistantSearch } : undefined - ) + sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts) }, - [ - workspaceId, - chatId, - composerMode, - memberAccessAvailable, - editingQueuedId, - cancelQueueEdit, - prepareResourceViewForAgentTurn, - sendMessage, - setSearchQuery, - ] + [workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage] ) - /** - * A queued message re-enters the composer in the mode it was written in: an - * Assistant question edits as an Assistant question, and never as a Search, - * which submits nothing and would leave the edit stranded. - */ - const restoreQueuedMode = useCallback( - (requestMode: QueuedMessage['requestMode']) => { - void setComposerMode(requestMode === 'assistant' ? 'assistant' : 'build') - }, - [setComposerMode] - ) - - /** An emptied search box returns to the sources; a send in any other mode has no search to clear. */ - const clearSearch = useCallback(() => { - if (searchQueryValue !== null) setSearchQuery('') - }, [searchQueryValue, setSearchQuery]) - - /** - * Summarize or Answer on a result: switch to Assistant and hand the question - * to it. The submit reads the mode from this render, so it is sent as an - * Assistant turn directly rather than waiting for the URL to update, and the - * box is emptied as a send empties it, so the query does not linger as a - * draft under the answer. - */ - const handleSummarize = async (prompt: string, assistantSearch: WorkspaceSearchFilters) => { - await setComposerMode('assistant') - initialViewUserInputRef.current?.clear() - chatViewUserInputRef.current?.clear() - void handleSubmit(prompt, undefined, undefined, 'assistant', assistantSearch) - } - const showSearchResults = composerMode === 'search' && searchQuery.trim().length > 0 - const searchResults = showSearchResults ? ( - - ) : null - /** * Handles cross-surface send requests (terminal/console "Fix in Chat", the * log "Troubleshoot in Chat" action). `preventDefault` claims the event so a @@ -778,25 +683,20 @@ export function Home({ chatId, userName, userId }: HomeProps) { > {/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
- {searchResults ?? ( - - initialViewUserInputRef.current?.populatePrompt(prompt) - } - /> - )} + + initialViewUserInputRef.current?.populatePrompt(prompt) + } + />
@@ -806,16 +706,9 @@ export function Home({ chatId, userName, userId }: HomeProps) { workspaceId={workspaceId} messages={messages} isSending={isSending} - searchResults={searchResults} - searchQuery={searchQuery} - userInputRef={chatViewUserInputRef} - onRestoreQueuedMode={restoreQueuedMode} isReconnecting={isReconnecting} isLoading={showChatSkeleton} onSubmit={handleSubmit} - canSearch={memberAccessAvailable} - clearOnSubmit={composerMode !== 'search'} - onCleared={clearSearch} onStopGeneration={handleStopGeneration} messageQueue={messageQueue} editingQueuedId={editingQueuedId} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts index 3c59c521738..820d5351ae6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts @@ -4,53 +4,26 @@ import { describe, expect, it } from 'vitest' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' -function withSearch(search: string) { - window.history.replaceState(null, '', `/workspace/ws-1/home${search}`) -} - describe('chatUrl', () => { - it('routes organization conversations to their owner without inventing a workspace', () => { - window.history.replaceState(null, '', '/o/org-1/home') - expect(chatUrl({ organizationId: 'org-1' }, 'chat-1', 'assistant')).toBe( - '/o/org-1/chat/chat-1?mode=assistant' - ) - }) - - it('carries the mode and the open resource onto the chat path', () => { - withSearch('?mode=assistant&resource=res-1') - expect(chatUrl('ws-1', 'chat-1')).toBe( - '/workspace/ws-1/chat/chat-1?mode=assistant&resource=res-1' - ) + it('routes organization conversations without adding a workspace or mode', () => { + window.history.replaceState(null, '', '/o/org-1/home?mode=assistant') + expect(chatUrl({ organizationId: 'org-1' }, 'chat-1')).toBe('/o/org-1/chat/chat-1') }) - it('leaves a search query and its filters behind', () => { - withSearch('?q=volvo&source=gmail&updated=7d&mode=assistant') - expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1?mode=assistant') - }) + it.each(['build', 'assistant', 'search', 'unknown'])( + 'preserves the resource while dropping legacy mode %s and search filters', + (mode) => { + window.history.replaceState( + null, + '', + `/workspace/ws-1/home?mode=${mode}&q=budget&source=upload&updated=7d&resource=report` + ) + expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1?resource=report') + } + ) - it('produces a clean path when nothing belongs on the chat', () => { - withSearch('?q=volvo') + it('produces a clean path when no resource is selected', () => { + window.history.replaceState(null, '', '/workspace/ws-1/home?q=budget') expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1') }) - - it('uses the submitted mode only when no view has been selected', () => { - withSearch('') - expect(chatUrl('ws-1', 'chat-1', 'assistant')).toBe( - '/workspace/ws-1/chat/chat-1?mode=assistant' - ) - expect(chatUrl('ws-1', 'chat-1', 'agent')).toBe('/workspace/ws-1/chat/chat-1?mode=build') - }) - - it.each([ - ['?mode=build', 'assistant', '?mode=build'], - ['?mode=assistant', 'agent', '?mode=assistant'], - [ - '?mode=search&q=budget&source=upload&updated=7d', - 'assistant', - '?mode=search&q=budget&source=upload&updated=7d', - ], - ] as const)('preserves a mode selected after submission: %s', (current, submitted, expected) => { - withSearch(current) - expect(chatUrl('ws-1', 'chat-1', submitted)).toBe(`/workspace/ws-1/chat/chat-1${expected}`) - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts index 3f02aec4e30..384dd2f1dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts @@ -1,33 +1,11 @@ -import { - modeParam, - resourceParam, - searchFilterParsers, - searchQueryParam, -} from '@/app/workspace/[workspaceId]/home/search-params' +import { resourceParam } from '@/app/workspace/[workspaceId]/home/search-params' -/** - * Preserve the view selected while a new chat was starting. The submitted turn - * supplies a fallback only; it cannot overwrite a subsequent mode switch. - */ -export function chatUrl( - owner: string | { organizationId: string }, - chatId: string, - requestMode?: 'agent' | 'assistant' -): string { +/** Preserve the selected resource when a new conversation receives its chat URL. */ +export function chatUrl(owner: string | { organizationId: string }, chatId: string): string { const current = new URLSearchParams(window.location.search) const carried = new URLSearchParams() - const mode = - modeParam.parser.parse(current.get(modeParam.key) ?? '') ?? - (requestMode === 'assistant' ? 'assistant' : requestMode === 'agent' ? 'build' : null) - if (mode) carried.set(modeParam.key, mode) - const keys = - mode === 'search' - ? [resourceParam.key, searchQueryParam.key, ...Object.keys(searchFilterParsers)] - : [resourceParam.key] - for (const key of keys) { - const value = current.get(key) - if (value) carried.set(key, value) - } + const resource = current.get(resourceParam.key) + if (resource) carried.set(resourceParam.key, resource) const search = carried.toString() const basePath = typeof owner === 'string' ? `/workspace/${owner}` : `/o/${owner.organizationId}` return `${basePath}/chat/${chatId}${search ? `?${search}` : ''}` diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index e6afef301d8..8c1fa13edd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -5,5 +5,4 @@ export { shouldActivateResourceEvent, useChat, } from './use-chat' -export { useMothershipMode } from './use-mothership-mode' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index 3bd6a2e6f25..abb0c6dd488 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -45,6 +45,17 @@ function browserUpsertEvent(id: string, title: string): PersistedStreamEventEnve } as PersistedStreamEventEnvelope } +function terminalUpsertEvent(id: string, title: string): PersistedStreamEventEnvelope { + return { + type: 'resource', + v: 1, + seq: 1, + ts: '', + stream: { streamId: 's', cursor: '1' }, + payload: { op: 'upsert', resource: { type: 'terminal', id, title } }, + } as PersistedStreamEventEnvelope +} + describe('handleResourceEvent removal', () => { beforeEach(() => { vi.clearAllMocks() @@ -86,7 +97,7 @@ describe('handleResourceEvent removal', () => { ) }) - it('normalizes a page-shaped browser event into the singleton Browser panel', () => { + it('ignores browser events because browser tabs come from the desktop tab list', () => { const onResourceEvent = vi.fn() const deps = makeStreamLoopDeps({ onResourceEventRef: { current: onResourceEvent }, @@ -98,13 +109,22 @@ describe('handleResourceEvent removal', () => { browserUpsertEvent('browser-session:slack-tab', 'mship-todo (Channel) - sim - Slack') ) - expect(deps.addResource).toHaveBeenCalledWith({ - type: 'browser', - id: 'browser-session', - title: 'Browser', + expect(deps.addResource).not.toHaveBeenCalled() + expect(deps.setActiveResourceId).not.toHaveBeenCalled() + expect(onResourceEvent).not.toHaveBeenCalled() + }) + it('ignores terminal events because terminal tabs come from the desktop tab list', () => { + const onResourceEvent = vi.fn() + const deps = makeStreamLoopDeps({ + onResourceEventRef: { current: onResourceEvent }, }) + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, terminalUpsertEvent('terminal-session', 'Terminal')) + + expect(deps.addResource).not.toHaveBeenCalled() expect(deps.setActiveResourceId).not.toHaveBeenCalled() - expect(onResourceEvent).toHaveBeenCalledWith('browser-session') + expect(onResourceEvent).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 5f17c4b057a..67ce8a15c72 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -4,14 +4,16 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' import type { FilePreviewSession } from '@/lib/copilot/request/session' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import { canonicalizeDesktopSessionResource } from '@/lib/copilot/resources/types' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { hasRenderableFilePreviewContent, shouldReplaceSession, } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' -import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { useTableViewPinStore } from '@/stores/table/view-pin/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -46,6 +48,9 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven if (!workspaceId) return const onResourceEvent = onResourceEventRef.current const payload = parsed.payload + // Browser and terminal tabs are projected from the desktop app's live + // lists, never from the stream; older servers announced them as resources. + if (payload.resource.type === 'browser' || payload.resource.type === 'terminal') return const shouldClearViewId = payload.resource.type === 'table' && payload.resource.clearViewId === true // A saved view the agent just created or edited: the table opens on it, and @@ -57,13 +62,13 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven payload.resource.viewId.trim() ? payload.resource.viewId : undefined - const resource = canonicalizeDesktopSessionResource({ + const resource: MothershipResource = { type: payload.resource.type as MothershipResourceType, id: payload.resource.id, title: typeof payload.resource.title === 'string' ? payload.resource.title : payload.resource.id, ...(pinnedViewId ? { viewId: pinnedViewId } : {}), - }) + } const resourceUpdate = shouldClearViewId ? { ...resource, clearViewId: true as const } : resource if (payload.op === MothershipStreamV1ResourceOp.remove) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.test.tsx new file mode 100644 index 00000000000..2563e158eac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.test.tsx @@ -0,0 +1,222 @@ +/** + * @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 type { MothershipResource } from '@/lib/copilot/resources/types' +import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const { sendBrowserPanelAction, openUrlInNewBrowserTab, openInPanelListeners } = vi.hoisted(() => ({ + sendBrowserPanelAction: vi.fn(), + openUrlInNewBrowserTab: vi.fn(), + openInPanelListeners: new Set<(url: string) => void>(), +})) + +vi.mock('@/lib/browser-agent/transport', () => ({ + sendBrowserPanelAction, + openUrlInNewBrowserTab, +})) +vi.mock('@/lib/browser-agent/open-in-panel', () => ({ + onOpenInBrowserPanel: (listener: (url: string) => void) => { + openInPanelListeners.add(listener) + return () => openInPanelListeners.delete(listener) + }, +})) + +const SCOPE = 'chat-1' + +function tab(tabId: string, active = false, title = `Page ${tabId}`) { + return { tabId, url: `https://example.com/${tabId}`, title, loading: false, active } +} + +function pushTabs(scopeId: string, tabs: ReturnType[], activeTabId: string | null) { + act(() => { + useBrowserSessionStore.getState().setTabsState({ scopeId, tabs, activeTabId }) + }) +} + +interface HostProps { + scopeId: string + resources: MothershipResource[] + activeResourceId: string | null + addResource: (resource: MothershipResource) => void + removeResource: (type: MothershipResource['type'], id: string) => void + selectResource: (id: string) => void + onResourceEvent: (id: string, options?: { activate?: boolean }) => void +} + +function Host(props: HostProps) { + useBrowserTabResources(props) + return null +} + +describe('useBrowserTabResources', () => { + let root: Root + let container: HTMLDivElement + const addResource = vi.fn() + const removeResource = vi.fn() + const selectResource = vi.fn() + const onResourceEvent = vi.fn() + + function render(overrides: Partial = {}) { + const props: HostProps = { + scopeId: SCOPE, + resources: [], + activeResourceId: null, + addResource, + removeResource, + selectResource, + onResourceEvent, + ...overrides, + } + act(() => root.render()) + return (next: Partial) => act(() => root.render()) + } + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.clearAllMocks() + openInPanelListeners.clear() + useBrowserSessionStore.setState({ activeScopeId: SCOPE, sessions: {} }) + act(() => useBrowserSessionStore.getState().activateScope(SCOPE)) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + it('projects each native page into a browser resource and removes closed pages', () => { + const rerender = render() + pushTabs(SCOPE, [tab('1', true), tab('2')], '1') + + expect(addResource.mock.calls.map(([resource]) => resource)).toEqual([ + { type: 'browser', id: '1', title: 'Page 1' }, + { type: 'browser', id: '2', title: 'Page 2' }, + ]) + + rerender({ + resources: [ + { type: 'browser', id: '1', title: 'Page 1' }, + { type: 'browser', id: '2', title: 'Page 2' }, + ], + }) + pushTabs(SCOPE, [tab('1', true)], '1') + expect(removeResource).toHaveBeenCalledExactlyOnceWith('browser', '2') + }) + + it('keeps projecting a tab until its resource lands, then treats absence as a close', () => { + const rerender = render() + pushTabs(SCOPE, [tab('1', true)], '1') + expect(addResource).toHaveBeenCalledTimes(1) + + // Chat hydration replaced the list before the add committed: project again. + rerender({ resources: [{ type: 'file', id: 'f', title: 'notes.md' }] }) + expect(addResource).toHaveBeenCalledTimes(2) + + rerender({ + resources: [ + { type: 'file', id: 'f', title: 'notes.md' }, + { type: 'browser', id: '1', title: 'Page 1' }, + ], + }) + addResource.mockClear() + + // The user closed the strip tab; the native close has not landed yet. + rerender({ resources: [{ type: 'file', id: 'f', title: 'notes.md' }] }) + expect(addResource).not.toHaveBeenCalled() + }) + + it('keeps the tabs through a pending-to-durable scope migration', () => { + const resources: MothershipResource[] = [{ type: 'browser', id: '1', title: 'Page 1' }] + const rerender = render({ scopeId: 'pending:new', resources }) + act(() => useBrowserSessionStore.getState().activateScope('pending:new')) + pushTabs('pending:new', [tab('1', true)], '1') + + // The store migrates first; the hook still points at the pending scope. + act(() => useBrowserSessionStore.getState().migrateScope('pending:new', SCOPE)) + expect(removeResource).not.toHaveBeenCalled() + + rerender({ scopeId: SCOPE, resources }) + expect(removeResource).not.toHaveBeenCalled() + expect(addResource).not.toHaveBeenCalled() + }) + + it('switches the native page when a browser tab is selected, without claiming it', () => { + const resources: MothershipResource[] = [ + { type: 'browser', id: '1', title: 'Page 1' }, + { type: 'browser', id: '2', title: 'Page 2' }, + ] + const rerender = render({ resources, activeResourceId: '1' }) + pushTabs(SCOPE, [tab('1', true), tab('2')], '1') + expect(sendBrowserPanelAction).not.toHaveBeenCalled() + + rerender({ activeResourceId: '2' }) + expect(sendBrowserPanelAction).toHaveBeenCalledExactlyOnceWith( + 'switch-tab', + { tabId: '2', claim: false }, + SCOPE + ) + + // The requested switch landing is not a native change to follow. + pushTabs(SCOPE, [tab('1'), tab('2', true)], '2') + expect(selectResource).not.toHaveBeenCalled() + }) + + it('follows a native switch into the strip only while the user is on the browser', () => { + const resources: MothershipResource[] = [ + { type: 'browser', id: '1', title: 'Page 1' }, + { type: 'browser', id: '2', title: 'Page 2' }, + { type: 'file', id: 'f', title: 'notes.md' }, + ] + const rerender = render({ resources, activeResourceId: '1' }) + pushTabs(SCOPE, [tab('1', true), tab('2')], '1') + + pushTabs(SCOPE, [tab('1'), tab('2', true)], '2') + expect(selectResource).toHaveBeenCalledExactlyOnceWith('2') + expect(sendBrowserPanelAction).not.toHaveBeenCalled() + + selectResource.mockClear() + rerender({ activeResourceId: 'f' }) + pushTabs(SCOPE, [tab('1', true), tab('2')], '1') + expect(selectResource).not.toHaveBeenCalled() + }) + + it('announces the agent tab as activity without moving the native page', () => { + render({ + resources: [{ type: 'browser', id: '1', title: 'Page 1' }], + activeResourceId: '1', + }) + pushTabs(SCOPE, [tab('1', true)], '1') + act(() => { + useBrowserSessionStore.getState().setTabsState({ + scopeId: SCOPE, + tabs: [tab('1', true), tab('2')], + activeTabId: '1', + automationTabId: '2', + automationActive: true, + }) + }) + + expect(onResourceEvent).toHaveBeenCalledExactlyOnceWith('2', { activate: true }) + expect(sendBrowserPanelAction).not.toHaveBeenCalled() + }) + + it('selects a chat link tab as the user own choice', async () => { + render() + openUrlInNewBrowserTab.mockResolvedValue('9') + + await act(async () => { + for (const listener of openInPanelListeners) listener('https://docs.sim.ai') + }) + + expect(openUrlInNewBrowserTab).toHaveBeenCalledWith('https://docs.sim.ai', SCOPE) + expect(selectResource).toHaveBeenCalledExactlyOnceWith('9') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.ts new file mode 100644 index 00000000000..51885bd161f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { BrowserTabState } from '@sim/browser-protocol' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel' +import { browserTabTitle } from '@/lib/browser-agent/tab-label' +import { openUrlInNewBrowserTab, sendBrowserPanelAction } from '@/lib/browser-agent/transport' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { + type DesktopTabResourceCallbacks, + useDesktopTabResources, +} from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const logger = createLogger('BrowserTabResources') + +const EMPTY_BROWSER_TABS: BrowserTabState[] = [] + +interface UseBrowserTabResourcesOptions extends DesktopTabResourceCallbacks { + /** Desktop browser scope whose pages back this chat's browser tabs. */ + scopeId: string + resources: readonly MothershipResource[] + activeResourceId: string | null +} + +function switchBrowserTab(tabId: string, scopeId: string): void { + sendBrowserPanelAction('switch-tab', { tabId, claim: false }, scopeId) +} + +/** + * Projects the desktop app's live browser pages into `browser` resource tabs, + * one per page. See {@link useDesktopTabResources} for the shared model. + */ +export function useBrowserTabResources({ + scopeId, + resources, + activeResourceId, + addResource, + removeResource, + selectResource, + onResourceEvent, +}: UseBrowserTabResourcesOptions): void { + const hasSession = useBrowserSessionStore((state) => state.sessions[scopeId] !== undefined) + const browserTabs = useBrowserSessionStore( + (state) => state.sessions[scopeId]?.tabs ?? EMPTY_BROWSER_TABS + ) + const activeTabId = useBrowserSessionStore( + (state) => state.sessions[scopeId]?.activeTabId ?? null + ) + const agentTabId = useBrowserSessionStore((state) => { + const session = state.sessions[scopeId] + if (!session) return null + return session.automationActive || session.agentRunIds.length > 0 + ? session.automationTabId + : null + }) + const tabs = useMemo( + () => browserTabs.map((tab) => ({ id: tab.tabId, title: browserTabTitle(tab) })), + [browserTabs] + ) + const scopeIdRef = useRef(scopeId) + scopeIdRef.current = scopeId + const selectResourceRef = useRef(selectResource) + selectResourceRef.current = selectResource + + useDesktopTabResources({ + type: 'browser', + scopeId, + tabs, + hasSession, + activeTabId, + agentTabId, + switchTab: switchBrowserTab, + resources, + activeResourceId, + addResource, + removeResource, + selectResource, + onResourceEvent, + }) + + // Chat links clicked in the desktop app open in a new browser tab. The user + // asked to see it, so it is selected as their own choice rather than offered + // through the agent-activity policy. + useEffect(() => { + return onOpenInBrowserPanel((url) => { + void openUrlInNewBrowserTab(url, scopeIdRef.current) + .then((tabId) => { + if (tabId) selectResourceRef.current(tabId) + }) + .catch((error) => { + logger.warn('Failed to open chat link in a new browser tab', { + error: getErrorMessage(error), + }) + }) + }) + }, []) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index eb33c508b74..7ec24fb8476 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -339,6 +339,41 @@ describe('useChat remount send recovery', () => { vi.clearAllMocks() }) + it.each([ + { options: undefined, expectedSource: 'drive' }, + { options: { assistantSearch: { source: 'slack' } }, expectedSource: 'slack' }, + ])( + 'preserves queued assistant mode when edited with $options', + async ({ options, expectedSource }) => { + useMothershipQueueStore.setState({ + queues: { + 'chat-a': [ + { + id: 'queued-question', + content: 'Find the policy', + requestMode: 'assistant', + assistantSearch: { source: 'drive' }, + }, + ], + }, + editing: { 'chat-a': 'queued-question' }, + }) + const { getResult } = renderUseChatInChat('chat-a') + + await act(async () => { + await getResult().sendMessage('Find the updated policy', undefined, undefined, options) + }) + await waitFor(() => state.postBodies.length === 1) + + expect(state.postBodies[0]).toMatchObject({ + message: 'Find the updated policy', + mode: 'assistant', + assistantSearch: { source: expectedSource }, + }) + expect(useMothershipQueueStore.getState().editing['chat-a']).toBeUndefined() + } + ) + it('keeps a cross-route handoff recoverable across a StrictMode double-mount', async () => { MothershipHandoffStorage.store({ message: 'investigate this failed run' }, 'ws-1') diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index 00e59d00ce8..b833f38856a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -10,7 +10,6 @@ import { import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' import { getReplayCompletedWorkflowToolCallIds, - panelForExecutingClientTool, reconcileLiveAssistantTurn, selectDeletedWorkflowResources, selectReconnectReplayState, @@ -18,11 +17,7 @@ import { shouldQueueOutgoingMessage, waitForDetachedChatResolution, } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' -import type { - ChatMessage, - ContentBlock, - ToolCallStatus, -} from '@/app/workspace/[workspaceId]/home/types' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' vi.mock('next/navigation', () => ({ usePathname: () => '/workspace/workspace-1/home', @@ -326,59 +321,3 @@ describe('getReplayCompletedWorkflowToolCallIds', () => { expect(result).toEqual(new Set(['workflow-complete'])) }) }) - -describe('panelForExecutingClientTool', () => { - function toolCallMessage(id: string, name: string, status: ToolCallStatus): ChatMessage { - return { - id, - role: 'assistant', - content: '', - contentBlocks: [{ type: 'tool_call', toolCall: { id: `${id}-tool`, name, status } }], - } - } - - it('detects a browser tool call that is still executing', () => { - const messages = [ - toolCallMessage('m1', 'browser_click', 'success'), - toolCallMessage('m2', 'browser_navigate', 'executing'), - ] - - expect(panelForExecutingClientTool(messages)).toBe('browser') - }) - - it('detects a terminal tool call that is still executing', () => { - const messages = [ - toolCallMessage('m1', 'terminal', 'success'), - toolCallMessage('m2', 'terminal', 'executing'), - ] - - expect(panelForExecutingClientTool(messages)).toBe('terminal') - }) - - it('ignores completed calls and executing tools that own no panel', () => { - const messages = [ - toolCallMessage('m1', 'browser_click', 'success'), - toolCallMessage('m2', 'terminal', 'success'), - toolCallMessage('m3', 'run_workflow', 'executing'), - { id: 'm4', role: 'assistant' as const, content: 'no blocks' }, - ] - - expect(panelForExecutingClientTool(messages)).toBe(null) - }) - - // Both panels can be in flight at once; the later call is the one the user - // was watching when they navigated away. - it('picks the later panel when both are mid-action', () => { - const browserFirst = [ - toolCallMessage('m1', 'browser_navigate', 'executing'), - toolCallMessage('m2', 'terminal', 'executing'), - ] - const terminalFirst = [ - toolCallMessage('m1', 'terminal', 'executing'), - toolCallMessage('m2', 'browser_navigate', 'executing'), - ] - - expect(panelForExecutingClientTool(browserFirst)).toBe('terminal') - expect(panelForExecutingClientTool(terminalFirst)).toBe('browser') - }) -}) 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 dfe600df579..6752c4cd6e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -31,12 +31,7 @@ import { } from '@/lib/api/contracts/mothership-chats' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { buildResourceAttachments } from '@/lib/browser-agent/attachments' -import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel' -import { - cancelActiveBrowserTools, - initBrowserAgentTransport, - openUrlInNewBrowserTab, -} from '@/lib/browser-agent/transport' +import { cancelActiveBrowserTools, initBrowserAgentTransport } from '@/lib/browser-agent/transport' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { toDisplayMessage } from '@/lib/copilot/chat/display-message' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' @@ -73,13 +68,11 @@ import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' import { canDisplayResource } from '@/lib/copilot/resources/availability' import { ResourcePersistenceQueue } from '@/lib/copilot/resources/client-persistence-queue' import { - BROWSER_SESSION_RESOURCE_ID, isAddressableResource, isEphemeralResource, type MothershipResourceUpdate, mergeChatResource, sanitizeChatResources, - TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' import { @@ -1153,28 +1146,38 @@ export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[] } /** - * Which live panel the transcript is mid-action on, or null for neither. - * - * Used on reconnect to restore that panel, the way workflow-run recovery - * restores workflows. A completed browser or terminal call is suppressed on - * replay, so it never re-opens its own panel — without this, returning to a - * chat mid-turn lands on whichever resource happened to be persisted last - * while the agent is driving a different one. When calls against both are in - * flight the later one wins, being the one the user was watching. + * Runs a browser tool on the desktop client. The agent's tab reaches the + * resource strip through the desktop tab list, so nothing is opened here. + * Replay/exactly-once guarding lives in executeBrowserToolOnClient + * (sessionStorage-backed, so reloads cannot re-run an action). */ -export function panelForExecutingClientTool( - messages: ChatMessage[] -): 'browser' | 'terminal' | null { - let panel: 'browser' | 'terminal' | null = null - for (const message of messages) { - for (const block of message.contentBlocks ?? []) { - const call = block.toolCall - if (call === undefined || call.status !== 'executing') continue - if (isBrowserToolName(call.name)) panel = 'browser' - else if (isTerminalToolName(call.name)) panel = 'terminal' - } - } - return panel +function startClientBrowserTool( + toolCallId: string, + toolName: string, + toolArgs: Record, + scopeId: string, + eventTs?: string, + signal?: AbortSignal +): void { + if (!isCurrentBrowserToolName(toolName)) return + executeBrowserToolOnClient(toolCallId, toolName, toolArgs, scopeId, eventTs, signal) +} + +/** + * Runs a terminal tool on the desktop client. The agent's shell reaches the + * resource strip through the desktop tab list, so nothing is opened here. + * Replay/exactly-once guarding lives in executeTerminalToolOnClient + * (sessionStorage-backed, so reloads cannot re-run a command). + */ +function startClientTerminalTool( + toolCallId: string, + toolName: string, + toolArgs: Record, + scopeId: string, + eventTs?: string +): void { + if (!isTerminalToolName(toolName)) return + executeTerminalToolOnClient(toolCallId, toolArgs, scopeId, eventTs) } function buildRecoverySubjectKey( @@ -1454,6 +1457,8 @@ export function useChat( const activeResourceIdRef = useRef(effectiveActiveResourceId) activeResourceIdRef.current = effectiveActiveResourceId + const selectedResourceIdRef = useRef(activeResourceId) + selectedResourceIdRef.current = activeResourceId const { previewSession, previewSessionRef, @@ -1859,11 +1864,7 @@ export function useChat( window.history.replaceState( null, '', - chatUrl( - organizationId ? { organizationId } : workspaceId!, - chatId, - activeTurn?.optimisticUserMessage.requestMode - ) + chatUrl(organizationId ? { organizationId } : workspaceId!, chatId) ) } if (options?.invalidateList) { @@ -2128,17 +2129,6 @@ export function useChat( [workspaceId, organizationId, scopeKey] ) - const openBrowserResource = useCallback(() => { - // Browser work surfaces like any other agent activity: the panel follows - // the agent to the browser whether or not the session was already open. - addResource({ - type: 'browser', - id: BROWSER_SESSION_RESOURCE_ID, - title: 'Browser', - }) - onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID, { activate: true }) - }, [addResource]) - const getResourceActivityTracker = useCallback( (generation: number, targetChatId?: string) => { let tracker = resourceActivityTrackerRef.current @@ -2201,67 +2191,6 @@ export function useChat( [workspaceId, organizationId, scopeKey] ) - const startClientBrowserTool = useCallback( - ( - toolCallId: string, - toolName: string, - toolArgs: Record, - scopeId: string, - eventTs?: string, - signal?: AbortSignal - ) => { - if (!isCurrentBrowserToolName(toolName)) { - return - } - openBrowserResource() - // Replay/exactly-once guarding lives in executeBrowserToolOnClient - // (sessionStorage-backed, so reloads cannot re-run an action). - executeBrowserToolOnClient(toolCallId, toolName, toolArgs, scopeId, eventTs, signal) - }, - [openBrowserResource] - ) - - const openTerminalResource = useCallback(() => { - addResource({ - type: 'terminal', - id: TERMINAL_SESSION_RESOURCE_ID, - title: 'Terminal', - }) - onResourceEventRef.current?.(TERMINAL_SESSION_RESOURCE_ID) - }, [addResource]) - - const startClientTerminalTool = useCallback( - ( - toolCallId: string, - toolName: string, - toolArgs: Record, - scopeId: string, - eventTs?: string - ) => { - if (!isTerminalToolName(toolName)) { - return - } - openTerminalResource() - // Replay/exactly-once guarding lives in executeTerminalToolOnClient - // (sessionStorage-backed, so reloads cannot re-run a command). - executeTerminalToolOnClient(toolCallId, toolArgs, scopeId, eventTs) - }, - [openTerminalResource] - ) - - // Chat links clicked in the desktop app open in the embedded browser panel - // (message components dispatch the request; this hook owns the resource). - useEffect(() => { - return onOpenInBrowserPanel((url) => { - openBrowserResource() - void openUrlInNewBrowserTab(url, desktopScopeIdRef.current).catch((error) => { - logger.warn('Failed to open chat link in a new browser tab', { - error: getErrorMessage(error), - }) - }) - }) - }, [openBrowserResource]) - const recoverPendingClientWorkflowTools = useCallback( async (nextMessages: ChatMessage[]) => { const pending: ToolCallInfo[] = [] @@ -2508,9 +2437,8 @@ export function useChat( flushPendingResources(chatHistory.id) - // Older clients persisted each live browser page as a top-level resource - // during new-chat creation. Collapse those legacy rows into the one - // restorable Browser panel so page titles never appear beside Browser. + // Browser and terminal rows stored by older clients are dropped: the + // desktop app's live tab lists are what put those tabs in the strip now. const persistedResources = sanitizeChatResources( chatHistory.resources.filter((r) => r.id !== 'streaming-file') ) @@ -2548,11 +2476,18 @@ export function useChat( ) if (mergedResources.length > 0) { + // An explicit selection wins. Otherwise fall back to the last resource + // the server holds, not the last on screen: local-only browser tabs can + // land before the history does, and which side arrives first must not + // decide which tab the chat opens on. + const selectedResourceId = selectedResourceIdRef.current const hydratedActiveResourceId = - activeResourceIdRef.current && - mergedResources.some((resource) => resource.id === activeResourceIdRef.current) - ? activeResourceIdRef.current - : mergedResources[mergedResources.length - 1].id + selectedResourceId && mergedResources.some((resource) => resource.id === selectedResourceId) + ? selectedResourceId + : ( + restorableResources[restorableResources.length - 1] ?? + mergedResources[mergedResources.length - 1] + ).id // Replacing the array with an identical one still re-renders the tab // strip and panel — skip the no-op so open panels don't flash. if (!resourcesUnchanged) { @@ -2574,16 +2509,6 @@ export function useChat( setActiveResourceId(null) } - // Live-panel counterpart of the workflow-run recovery above: returning to - // a chat whose turn is mid browser-action or mid-command re-focuses that - // tab and re-expands a collapsed panel. Runs after the resource hydration - // so it wins over the "last resource" active fallback. - if (shouldReconnectActiveStream) { - const panel = panelForExecutingClientTool(mappedMessages) - if (panel === 'browser') openBrowserResource() - else if (panel === 'terminal') openTerminalResource() - } - const snapshotPreviewSessions = Array.isArray(chatHistory.streamSnapshot?.previewSessions) ? (chatHistory.streamSnapshot.previewSessions as FilePreviewSession[]) : [] @@ -2649,8 +2574,6 @@ export function useChat( cancelActiveStreamReader, cancelActiveStreamRecovery, flushPendingResources, - openBrowserResource, - openTerminalResource, reconcileHydratedWorkflowResources, recoverPendingClientWorkflowTools, seedPreviewSessions, @@ -2679,7 +2602,6 @@ export function useChat( ) const activityScopeId = () => activityTracker.currentScopeId const startBrowserAgentRunForStream = (runId: string) => { - openBrowserResource() const scopeId = activityScopeId() setTrackedBrowserRun(activityTracker, scopeId, runId, true) } @@ -2849,11 +2771,9 @@ export function useChat( removeResource, startClientWorkflowTool, startClientLocalFilesystemTool, - startClientBrowserTool, startClientTerminalTool, getResourceActivityTracker, clearResourceActivity, - openBrowserResource, adoptResolvedChatId, upsertChatHistory, onPreviewPhase, @@ -4428,14 +4348,14 @@ export function useChat( // Edit-in-place: replace at the original index. If the slot was already // dispatched mid-edit (UI-guard race), fall through to a tail-append. if (editingId) { - const existing = queueStore.queues[activeChatKey] ?? [] - if (existing.some((m) => m.id === editingId)) { + const existing = queueStore.queues[activeChatKey]?.find((m) => m.id === editingId) + if (existing) { queueStore.replaceAt(activeChatKey, editingId, { content: message, fileAttachments, contexts, - requestMode: options?.requestMode, - assistantSearch: options?.assistantSearch, + requestMode: options?.requestMode ?? existing.requestMode, + assistantSearch: options?.assistantSearch ?? existing.assistantSearch, }) queueStore.setEditing(activeChatKey, null) // Resume dispatch if it paused on this slot. diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts new file mode 100644 index 00000000000..865ad133f15 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts @@ -0,0 +1,160 @@ +import { useEffect, useRef } from 'react' +import type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' +import type { ResourceEventHandler } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' + +/** One live desktop tab, as the strip needs to know it. */ +export interface DesktopTab { + id: string + title: string +} + +export interface DesktopTabResourceCallbacks { + /** Adds a tab without activating it; activation goes through {@link onResourceEvent}. */ + addResource: (resource: MothershipResource) => void + removeResource: (resourceType: MothershipResourceType, resourceId: string) => void + /** Explicit user selection, which claims the strip's selection for the user. */ + selectResource: (resourceId: string) => void + /** Agent activity on a tab, subject to the panel's user-ownership policy. */ + onResourceEvent: ResourceEventHandler +} + +interface UseDesktopTabResourcesOptions extends DesktopTabResourceCallbacks { + type: 'browser' | 'terminal' + /** Desktop scope whose live tabs back this chat's resource tabs. */ + scopeId: string + /** The desktop app's live tab list for the scope, in its order. */ + tabs: readonly DesktopTab[] + /** + * Whether the renderer holds a bucket for the scope at all. A missing bucket + * means the scope has not been activated yet or was just migrated to its + * durable id; it says nothing about the tabs themselves. + */ + hasSession: boolean + /** The tab the desktop app currently shows for the scope. */ + activeTabId: string | null + /** The tab the agent is working in, while it is working. */ + agentTabId: string | null + /** Shows a tab natively without claiming it for the user. */ + switchTab: (tabId: string, scopeId: string) => void + resources: readonly MothershipResource[] + activeResourceId: string | null +} + +/** + * Keeps one kind of desktop-backed resource tab equal to the desktop app's + * live tab list, one resource per native tab. + * + * The desktop app owns the tabs, so its list is the source of truth: a tab + * appearing there gains a resource tab and a tab leaving it loses one. Closing + * a resource tab closes its native tab at the strip, which then comes back + * through the same list. Visible selection is routed the same way — choosing + * a resource tab switches the native tab, and a native switch follows into the + * strip while the user is on that kind of tab. + * + * The agent never moves the visible tab itself. Its tab is announced as + * resource activity, so the existing view policy decides whether to show it or + * only badge it while the user is reading something else. + */ +export function useDesktopTabResources({ + type, + scopeId, + tabs, + hasSession, + activeTabId, + agentTabId, + switchTab, + resources, + activeResourceId, + addResource, + removeResource, + selectResource, + onResourceEvent, +}: UseDesktopTabResourcesOptions): void { + /** + * Tab ids whose resource has been seen in the strip for the current scope. + * A tab is projected until its resource shows up — chat hydration can + * replace the list underneath a fresh add — and once it has been seen, its + * absence means the user closed it and the native close is in flight. + */ + const knownTabIdsRef = useRef | null>(null) + knownTabIdsRef.current ??= new Set() + const knownScopeRef = useRef(scopeId) + /** The native switch this hook asked for and has not seen land yet. */ + const requestedTabIdRef = useRef(null) + const scopeIdRef = useRef(scopeId) + scopeIdRef.current = scopeId + const tabsRef = useRef(tabs) + tabsRef.current = tabs + const activeTabIdRef = useRef(activeTabId) + activeTabIdRef.current = activeTabId + const resourcesRef = useRef(resources) + resourcesRef.current = resources + const activeResourceIdRef = useRef(activeResourceId) + activeResourceIdRef.current = activeResourceId + const switchTabRef = useRef(switchTab) + switchTabRef.current = switchTab + const selectResourceRef = useRef(selectResource) + selectResourceRef.current = selectResource + const onResourceEventRef = useRef(onResourceEvent) + onResourceEventRef.current = onResourceEvent + + useEffect(() => { + const known = knownTabIdsRef.current + if (!known) return + if (knownScopeRef.current !== scopeId) { + knownScopeRef.current = scopeId + known.clear() + requestedTabIdRef.current = null + } + const resourceTabIds = new Set( + resources.filter((resource) => resource.type === type).map((resource) => resource.id) + ) + + for (const tab of tabs) { + if (resourceTabIds.has(tab.id)) { + known.add(tab.id) + continue + } + if (!known.has(tab.id)) addResource({ type, id: tab.id, title: tab.title }) + } + + if (!hasSession) return + const liveTabIds = new Set(tabs.map((tab) => tab.id)) + for (const tabId of known) { + if (liveTabIds.has(tabId)) continue + known.delete(tabId) + if (resourceTabIds.has(tabId)) removeResource(type, tabId) + } + }, [addResource, hasSession, removeResource, resources, scopeId, tabs, type]) + + // Selecting a resource tab shows its native tab. Keyed on the selection + // alone: a native push must not re-assert a selection it just moved away + // from, or the two sides would trade switches forever. + useEffect(() => { + if (!activeResourceId || activeResourceId === activeTabIdRef.current) return + if (!tabsRef.current.some((tab) => tab.id === activeResourceId)) return + requestedTabIdRef.current = activeResourceId + switchTabRef.current(activeResourceId, scopeIdRef.current) + }, [activeResourceId]) + + // A native switch while the user is on this kind of tab follows into the + // strip. The switch this hook requested itself is not a native change of mind. + useEffect(() => { + if (requestedTabIdRef.current === activeTabId) { + requestedTabIdRef.current = null + return + } + const activeResource = resourcesRef.current.find( + (resource) => resource.id === activeResourceIdRef.current + ) + if (!activeTabId || activeResource?.type !== type || activeResource.id === activeTabId) { + return + } + selectResourceRef.current(activeTabId) + }, [activeTabId, type]) + + // The agent's tab surfaces like any other agent activity. + useEffect(() => { + if (agentTabId) onResourceEventRef.current(agentTabId, { activate: true }) + }, [agentTabId]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx deleted file mode 100644 index 4b789cb1d92..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params' - -const { mockMemberAccessAvailable, history } = vi.hoisted(() => ({ - mockMemberAccessAvailable: vi.fn(() => true), - history: { - messages: [] as { role: 'user' | 'assistant'; requestMode?: 'agent' | 'assistant' }[], - }, -})) -const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() - -vi.mock('@/hooks/use-member-access', () => ({ - useMemberAccessAvailable: () => mockMemberAccessAvailable(), -})) -vi.mock('next/navigation', () => ({ - useParams: () => ({ workspaceId: 'workspace-1', chatId: 'chat-1' }), - usePathname: () => '/workspace/workspace-1/home', - useRouter: () => ({ push: vi.fn() }), -})) -vi.mock('@/hooks/queries/mothership-chats', () => ({ - useMothershipChatHistory: () => ({ data: history }), -})) - -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' - -let root: Root | null = null -let container: HTMLDivElement | null = null -let current: ReturnType | null = null - -function Probe() { - current = useMothershipMode() - return null -} - -function mount(searchParams = '') { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - navigate(searchParams) -} - -function navigate(searchParams: string) { - act(() => - root?.render( - - - - ) - ) -} - -function mode(): MothershipMode { - if (!current) throw new Error('Probe did not render') - return current[0] -} - -/** nuqs batches its URL write onto a timeout, so a write is read back after the tick. */ -async function setMode(next: MothershipMode) { - await act(async () => { - current?.[1](next) - await vi.advanceTimersByTimeAsync(1) - }) -} - -beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - mockMemberAccessAvailable.mockReturnValue(true) - history.messages = [] - mockUrlUpdate.mockClear() -}) - -afterEach(() => { - if (root) act(() => root?.unmount()) - container?.remove() - root = null - container = null - current = null - vi.useRealTimers() -}) - -/** - * The mode's ordinary read/write behavior is covered through the UI in - * `mode-switcher.test.tsx`; one write stands here as the control the - * per-member-access cases are read against. - */ -describe('useMothershipMode', () => { - it('writes the chosen mode to the URL', async () => { - mount() - await setMode('search') - - expect(mode()).toBe('search') - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search') - }) - - it.each([ - ['agent', 'assistant', 'assistant'], - ['assistant', 'agent', 'build'], - ] as const)('resumes the latest user mode, %s then %s', (first, last, expected) => { - history.messages = [ - { role: 'user', requestMode: first }, - { role: 'user', requestMode: last }, - { role: 'assistant', requestMode: first }, - ] - mount() - expect(mode()).toBe(expected) - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it.each(['build', 'search', 'assistant'] as const)( - 'respects explicit URL mode %s on reload', - (explicit) => { - history.messages = [{ role: 'user', requestMode: 'assistant' }] - mount(`?mode=${explicit}`) - expect(mode()).toBe(explicit) - } - ) - - it('opens a query-only link in Search without writing a mode or message', () => { - mount('?q=budget') - expect(mode()).toBe('search') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('keeps explicit Build even when the URL also contains a query', () => { - mount('?mode=build&q=budget') - expect(mode()).toBe('build') - }) - - it('follows back and forward URL changes without overriding them from history', () => { - history.messages = [{ role: 'user', requestMode: 'assistant' }] - mount('?mode=build') - expect(mode()).toBe('build') - navigate('?mode=search&q=budget') - expect(mode()).toBe('search') - navigate('?mode=build') - expect(mode()).toBe('build') - navigate('') - expect(mode()).toBe('assistant') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('keeps the selected mode when an earlier in-flight turn finishes persisting', async () => { - history.messages = [{ role: 'user', requestMode: 'agent' }] - mount() - await setMode('search') - history.messages = [...history.messages, { role: 'user', requestMode: 'assistant' }] - navigate('?mode=search') - expect(mode()).toBe('search') - }) - - describe('without per-member access', () => { - beforeEach(() => { - mockMemberAccessAvailable.mockReturnValue(false) - }) - - it('reads Build from a link naming a mode the workspace does not have', () => { - mount('?mode=search') - - expect(mode()).toBe('build') - }) - - it('writes no mode the workspace does not have', async () => { - mount() - await setMode('search') - - expect(mode()).toBe('build') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('still returns to Build, so a stale link can be left', async () => { - mount('?mode=search&q=budget') - await setMode('build') - - expect(mode()).toBe('build') - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('mode=build') - }) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts deleted file mode 100644 index 13af3aa9a2c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import { useCallback } from 'react' -import { useParams } from 'next/navigation' -import { useQueryStates } from 'nuqs' -import { - CLEARED_SEARCH_FILTERS, - composerModeParsers, - type MothershipMode, - resourceUrlKeys, -} from '@/app/workspace/[workspaceId]/home/search-params' -import { useMothershipChatHistory } from '@/hooks/queries/mothership-chats' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' - -/** - * URL selection owns the current view and next turn. A bare chat link resumes - * the latest persisted user mode without changing the mode of any active run. - */ -export function useMothershipMode() { - const memberAccessAvailable = useMemberAccessAvailable() - const { chatId } = useParams<{ chatId?: string }>() - const [{ mode: urlMode, q: query }, setParams] = useQueryStates( - composerModeParsers, - resourceUrlKeys - ) - const { data: chatHistory } = useMothershipChatHistory(chatId) - let persistedMode: 'agent' | 'assistant' | undefined - for (const message of chatHistory?.messages ?? []) { - if (message.role === 'user') persistedMode = message.requestMode - } - const mode = - urlMode ?? (query?.trim() ? 'search' : persistedMode === 'assistant' ? 'assistant' : 'build') - const setMode = useCallback( - async (next: MothershipMode) => { - if (next !== 'build' && !memberAccessAvailable) return - return setParams( - { - mode: next, - ...(next === 'search' ? {} : { q: null, ...CLEARED_SEARCH_FILTERS }), - }, - { history: 'replace', scroll: false } - ) - }, - [setParams, memberAccessAvailable] - ) - - return [memberAccessAvailable ? mode : 'build', setMode] as const -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources.test.tsx new file mode 100644 index 00000000000..ef79b3ed7c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources.test.tsx @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import type { TerminalTabState } from '@sim/terminal-protocol' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { useTerminalTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources' +import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' + +const { switchTerminal } = vi.hoisted(() => ({ + switchTerminal: vi.fn(async () => {}), +})) + +vi.mock('@/lib/terminal/transport', () => ({ switchTerminal })) + +const SCOPE = 'chat-1' + +function shell( + terminalId: string, + active = false, + running: string | null = null +): TerminalTabState { + return { + terminalId, + title: `dir-${terminalId}`, + cwd: `/code/${terminalId}`, + running, + interactive: false, + active, + } +} + +function pushTabs(scopeId: string, tabs: TerminalTabState[], activeTerminalId: string | null) { + act(() => { + useCopilotTerminalStore.getState().setTabs({ scopeId, tabs, activeTerminalId }) + }) +} + +interface HostProps { + scopeId: string + resources: MothershipResource[] + activeResourceId: string | null + addResource: (resource: MothershipResource) => void + removeResource: (type: MothershipResource['type'], id: string) => void + selectResource: (id: string) => void + onResourceEvent: (id: string, options?: { activate?: boolean }) => void +} + +function Host(props: HostProps) { + useTerminalTabResources(props) + return null +} + +describe('useTerminalTabResources', () => { + let root: Root + let container: HTMLDivElement + const addResource = vi.fn() + const removeResource = vi.fn() + const selectResource = vi.fn() + const onResourceEvent = vi.fn() + + function render(overrides: Partial = {}) { + const props: HostProps = { + scopeId: SCOPE, + resources: [], + activeResourceId: null, + addResource, + removeResource, + selectResource, + onResourceEvent, + ...overrides, + } + act(() => root.render()) + return (next: Partial) => act(() => root.render()) + } + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.clearAllMocks() + useCopilotTerminalStore.setState({ + activeScopeId: SCOPE, + sessions: {}, + settledAgentCommandIds: [], + }) + act(() => useCopilotTerminalStore.getState().activateScope(SCOPE)) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + it('projects each live shell into a terminal resource and removes closed shells', () => { + const rerender = render() + pushTabs(SCOPE, [shell('1', true), shell('2')], '1') + + expect(addResource.mock.calls.map(([resource]) => resource)).toEqual([ + { type: 'terminal', id: 'terminal:1', title: 'dir-1' }, + { type: 'terminal', id: 'terminal:2', title: 'dir-2' }, + ]) + + rerender({ + resources: [ + { type: 'terminal', id: 'terminal:1', title: 'dir-1' }, + { type: 'terminal', id: 'terminal:2', title: 'dir-2' }, + ], + }) + pushTabs(SCOPE, [shell('1', true)], '1') + expect(removeResource).toHaveBeenCalledExactlyOnceWith('terminal', 'terminal:2') + }) + + it('shows the selected shell without claiming it, and ignores the switch landing', () => { + const resources: MothershipResource[] = [ + { type: 'terminal', id: 'terminal:1', title: 'dir-1' }, + { type: 'terminal', id: 'terminal:2', title: 'dir-2' }, + ] + const rerender = render({ resources, activeResourceId: 'terminal:1' }) + pushTabs(SCOPE, [shell('1', true), shell('2')], '1') + expect(switchTerminal).not.toHaveBeenCalled() + + rerender({ activeResourceId: 'terminal:2' }) + expect(switchTerminal).toHaveBeenCalledExactlyOnceWith('2', SCOPE, { claim: false }) + + pushTabs(SCOPE, [shell('1'), shell('2', true)], '2') + expect(selectResource).not.toHaveBeenCalled() + }) + + it('follows a native switch into the strip only while the user is on a terminal', () => { + const resources: MothershipResource[] = [ + { type: 'terminal', id: 'terminal:1', title: 'dir-1' }, + { type: 'terminal', id: 'terminal:2', title: 'dir-2' }, + { type: 'file', id: 'f', title: 'notes.md' }, + ] + const rerender = render({ resources, activeResourceId: 'terminal:1' }) + pushTabs(SCOPE, [shell('1', true), shell('2')], '1') + + pushTabs(SCOPE, [shell('1'), shell('2', true)], '2') + expect(selectResource).toHaveBeenCalledExactlyOnceWith('terminal:2') + + selectResource.mockClear() + rerender({ activeResourceId: 'f' }) + pushTabs(SCOPE, [shell('1', true), shell('2')], '1') + expect(selectResource).not.toHaveBeenCalled() + }) + + it('announces the shell running an agent command as activity', () => { + render({ + resources: [ + { type: 'terminal', id: 'terminal:1', title: 'dir-1' }, + { type: 'terminal', id: 'terminal:2', title: 'dir-2' }, + ], + activeResourceId: 'terminal:1', + }) + pushTabs(SCOPE, [shell('1', true), shell('2')], '1') + act(() => { + useCopilotTerminalStore.getState().applyCommandEvent({ + scopeId: SCOPE, + terminalId: '2', + phase: 'start', + command: 'bun test', + toolCallId: 'tool-1', + }) + }) + + expect(onResourceEvent).toHaveBeenCalledExactlyOnceWith('terminal:2', { activate: true }) + expect(switchTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources.ts new file mode 100644 index 00000000000..e794b0debd7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources.ts @@ -0,0 +1,77 @@ +import { useMemo } from 'react' +import type { TerminalTabState } from '@sim/terminal-protocol' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { terminalIdFromResourceId, terminalResourceId } from '@/lib/terminal/resource-id' +import { switchTerminal } from '@/lib/terminal/transport' +import { + type DesktopTabResourceCallbacks, + useDesktopTabResources, +} from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources' +import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' + +const EMPTY_TERMINAL_TABS: TerminalTabState[] = [] + +interface UseTerminalTabResourcesOptions extends DesktopTabResourceCallbacks { + /** Desktop terminal scope whose shells back this chat's terminal tabs. */ + scopeId: string + resources: readonly MothershipResource[] + activeResourceId: string | null +} + +function showTerminal(resourceId: string, scopeId: string): void { + void switchTerminal(terminalIdFromResourceId(resourceId), scopeId, { claim: false }).catch( + () => {} + ) +} + +/** + * Projects the desktop app's live shells into `terminal` resource tabs, one + * per shell. See {@link useDesktopTabResources} for the shared model. + */ +export function useTerminalTabResources({ + scopeId, + resources, + activeResourceId, + addResource, + removeResource, + selectResource, + onResourceEvent, +}: UseTerminalTabResourcesOptions): void { + const hasSession = useCopilotTerminalStore((state) => state.sessions[scopeId] !== undefined) + const terminalTabs = useCopilotTerminalStore( + (state) => state.sessions[scopeId]?.tabs.tabs ?? EMPTY_TERMINAL_TABS + ) + const activeTerminalId = useCopilotTerminalStore( + (state) => state.sessions[scopeId]?.tabs.activeTerminalId ?? null + ) + // A running agent command is the precise signal that the agent is working + // in a shell; the desktop's agent cursor alone only says where it would. + const agentTerminalId = useCopilotTerminalStore((state) => { + const session = state.sessions[scopeId] + if (!session) return null + const [terminalId] = Object.values(session.agentCommandTerminalIds) + return terminalId ?? null + }) + // The stored title is only a fallback: the strip derives the live label, + // including a settled command name, from the same store itself. + const tabs = useMemo( + () => terminalTabs.map((tab) => ({ id: terminalResourceId(tab.terminalId), title: tab.title })), + [terminalTabs] + ) + + useDesktopTabResources({ + type: 'terminal', + scopeId, + tabs, + hasSession, + activeTabId: activeTerminalId && terminalResourceId(activeTerminalId), + agentTabId: agentTerminalId && terminalResourceId(agentTerminalId), + switchTab: showTerminal, + resources, + activeResourceId, + addResource, + removeResource, + selectResource, + onResourceEvent, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts index a96d94d71d3..19401fa6e00 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts @@ -26,36 +26,6 @@ export const resourceUrlKeys = { clearOnDefault: true, } as const -/** - * `q` is the composer's Search-mode query, so a search is a shareable, - * bookmarkable link. Present only while a search is showing: it is dropped - * when the box empties, on Summarize, and when the mode leaves Search. The - * composer reads it once on mount to restore the query and the Search mode. - * Filter-like, so it replaces the history entry. - */ -export const searchQueryParam = { - key: 'q', - parser: parseAsString, -} as const - -/** The composer's modes: the agent, enterprise search, or the assistant answering from the sources. */ -export const MOTHERSHIP_MODES = ['build', 'search', 'assistant'] as const - -export type MothershipMode = (typeof MOTHERSHIP_MODES)[number] - -/** - * `mode` is the composer's mode, so a refresh, back, forward, or shared link - * lands in the same mode. A missing value falls back to the latest user turn; - * an explicit Build selection stays in the URL to distinguish it from that fallback. - */ -export const modeParam = { - key: 'mode', - parser: parseAsStringLiteral(MOTHERSHIP_MODES).withOptions({ - history: 'replace', - clearOnDefault: true, - }), -} as const - /** The recency windows a search can be narrowed to. */ export const UPDATED_WINDOWS = [ { id: 'any', label: 'Any time', days: null }, @@ -65,21 +35,10 @@ export const UPDATED_WINDOWS = [ const UPDATED_WINDOW_IDS = UPDATED_WINDOWS.map((window) => window.id) /** - * The result filters, beside `q`, so a narrowed search is the same shareable - * link as the search itself. `source` is a connector type or `upload`, absent - * for every source; both are dropped with the query. + * Shared result filters for organization search. `source` is a connector type + * or `upload`, absent for every source. */ export const searchFilterParsers = { source: parseAsString, updated: parseAsStringLiteral(UPDATED_WINDOW_IDS).withDefault('any'), } as const - -/** Every search param at its default: what leaving a search writes. */ -export const CLEARED_SEARCH_FILTERS = { source: null, updated: null } as const - -/** A mode transition clears its search query and filters in the same URL update. */ -export const composerModeParsers = { - [modeParam.key]: modeParam.parser, - [searchQueryParam.key]: searchQueryParam.parser, - ...searchFilterParsers, -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index e1b3d63d2b0..d8ae8a16521 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -551,7 +551,16 @@ function AtlassianServiceAccountModalForm({ Add {serviceName} service account - + {(aria) => ( { /** @@ -1012,6 +1014,8 @@ export function Document({ {processingStatus === 'pending' && 'Document processing pending...'} {processingStatus === 'processing' && 'Document processing in progress...'} {processingStatus === 'failed' && 'Document processing failed'} + {processingStatus === 'skipped' && + (processingError ? `Skipped · ${processingError}` : 'Document skipped')} {!processingStatus && 'Document not ready'}
@@ -1056,7 +1060,7 @@ export function Document({ }, } }) - }, [isCompleted, hasDocumentData, processingStatus, displayChunks, searchQuery]) + }, [isCompleted, hasDocumentData, processingStatus, processingError, displayChunks, searchQuery]) const saveLabel = saveStatus === 'saving' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 123316bd61c..61693dfadd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -47,7 +47,11 @@ import { getFieldTypeForSlot, KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS, } from '@/lib/knowledge/constants' -import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { + type DocumentSortField, + getDocumentIndexingStatus, + type SortOrder, +} from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import type { DocumentData } from '@/lib/knowledge/types' import { captureEvent } from '@/lib/posthog/client' @@ -175,7 +179,7 @@ const AnimatedLoader = ({ className }: { className?: string }) => ( ) const getStatusBadge = (doc: DocumentData) => { - switch (doc.processingStatus) { + switch (getDocumentIndexingStatus(doc)) { case 'pending': return ( @@ -188,6 +192,12 @@ const getStatusBadge = (doc: DocumentData) => { Processing ) + case 'skipped': + return ( + + Skipped + + ) case 'failed': return doc.processingError ? ( @@ -1558,7 +1568,8 @@ export function KnowledgeBase({ : undefined } onRetry={ - contextMenuDocument?.processingStatus === 'failed' && + contextMenuDocument && + getDocumentIndexingStatus(contextMenuDocument) === 'failed' && selectedDocumentCount === 1 && userPermissions.canEdit ? () => handleRetryDocument(contextMenuDocument.id) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx index 2dbccfee687..4c848f9bc01 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx @@ -13,18 +13,20 @@ import type { ConfigFieldMap } from '@/app/workspace/[workspaceId]/knowledge/[id const mocks = vi.hoisted(() => ({ create: vi.fn(), accountsQuery: vi.fn(), + oauthQuery: vi.fn(), configFields: vi.fn(), resolveSourceConfig: vi.fn((): Record => ({})), sourceConfig: {} as ConfigFieldMap, selectionLabels: undefined as SourceSelectionLabels | undefined, canonicalModes: {} as Record, isFieldPopulated: vi.fn(() => true), - credentials: [] as Pick[], + credentials: [] as (Pick & + Partial>)[], credentialsState: 'ready' as 'ready' | 'loading' | 'error', refetchCredentials: vi.fn(), oauthModal: vi.fn(), serviceAccountModal: vi.fn(), - githubInstallationModal: vi.fn(), + githubSetup: vi.fn(), serviceAccountTarget: null as ServiceAccountConnectTarget | null, memberAccess: true, mirroredAccess: true, @@ -130,14 +132,17 @@ vi.mock('@/hooks/queries/source-accounts', () => ({ }, })) vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ - useOAuthCredentials: () => ({ - data: mocks.credentials, - isLoading: mocks.credentialsState === 'loading', - isSuccess: mocks.credentialsState === 'ready', - isFetching: mocks.credentialsState === 'loading', - error: mocks.credentialsState === 'error' ? new Error('Could not load accounts') : null, - refetch: mocks.refetchCredentials, - }), + useOAuthCredentials: (...args: unknown[]) => { + mocks.oauthQuery(...args) + return { + data: mocks.credentials, + isLoading: mocks.credentialsState === 'loading', + isSuccess: mocks.credentialsState === 'ready', + isFetching: mocks.credentialsState === 'loading', + error: mocks.credentialsState === 'error' ? new Error('Could not load accounts') : null, + refetch: mocks.refetchCredentials, + } + }, })) vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.fn() })) vi.mock('@/hooks/use-credential-refresh-triggers', () => ({ @@ -158,6 +163,7 @@ vi.mock( - ) + mocks.githubSetup(props) + return { + pending: false, + error: null, + cancel: vi.fn(), + connect: () => { + mocks.credentials = [ + { + id: 'github-app-credential', + name: 'GitHub App: acme', + type: 'service_account', + provider: 'github-app-installation', + }, + ] + props.onConnected('github-app-credential') + }, + } }, })) vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields', () => ({ @@ -413,6 +423,58 @@ describe('Slack member setup readiness', () => { }) describe('Search methods requiring member identity', () => { + it.each(['browsing', 'content'] as const)( + 'assigns a new service account to the %s field without changing the other credential', + async (field) => { + mocks.serviceAccountTarget = { + serviceAccountProviderId: 'google-service-account', + serviceName: 'Google', + serviceIcon: googleDriveConnectorMeta.icon, + label: 'Add service account', + hidden: false, + } + await render({ + initialConnectorType: 'google_drive', + initialAccessMode: 'members', + scope: { kind: 'organization', organizationId: 'org-1' }, + }) + await act(async () => button('More options').click()) + await act(async () => combobox('Source account').click()) + const sourceOption = Array.from( + document.querySelectorAll('[role="option"]') + ).find((option) => option.textContent?.trim() === 'Source account') + expect(sourceOption).toBeDefined() + await act(async () => + sourceOption?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + ) + await act(async () => + combobox(field === 'content' ? 'Connected members' : 'Source account').click() + ) + const serviceOption = Array.from( + document.querySelectorAll('[role="option"]') + ).find((option) => option.textContent?.trim() === 'Add service account') + expect(serviceOption).toBeDefined() + await act(async () => + serviceOption?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + ) + await act(async () => button('Finish service account setup').click()) + + expect(combobox('New service account')).toBeDefined() + expect(configFieldsProps().credentialId).toBe( + field === 'content' ? 'credential-1' : 'new-service-account' + ) + expect(combobox(field === 'content' ? 'Source account' : 'Connected members')).toBeDefined() + await act(async () => button('Set up member accounts').click()) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + accessMode: 'members', + credentialId: field === 'content' ? 'new-service-account' : undefined, + }), + expect.any(Object) + ) + } + ) + it('selects a GitHub installation for content while preserving member access', async () => { mocks.resolveSourceConfig.mockReturnValue({ repository: 'acme/docs' }) await render({ @@ -420,19 +482,23 @@ describe('Search methods requiring member identity', () => { initialAccessMode: 'members', scope: { kind: 'organization', organizationId: 'org-1' }, }) - expect(document.body.textContent).toContain('Sync documents with') - await act(async () => combobox('Connected members').click()) - const option = Array.from(document.querySelectorAll('[role="option"]')).find( - (node) => node.textContent?.trim() === 'Connect GitHub App' - ) - if (!option) throw new Error('Missing GitHub App option') - await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) - expect(mocks.githubInstallationModal).toHaveBeenCalledWith( + expect(document.body.textContent).not.toContain('Sync documents with') + expect(document.body.textContent).not.toContain('Connected members') + expect(button('Add repository')).toBeDisabled() + await act(async () => button('Connect GitHub').click()) + expect(mocks.githubSetup).toHaveBeenCalledWith( expect.objectContaining({ organizationId: 'org-1' }) ) - await act(async () => button('Use GitHub installation').click()) - expect(combobox('GitHub App: acme')).toBeDefined() - await act(async () => button('Add source').click()) + expect(document.body.textContent).not.toContain('Use installation') + expect(document.body.textContent).not.toContain('Refresh') + expect(document.body.textContent).toContain('GitHub App: acme') + expect( + configFieldsProps().connectorConfig.configFields.find((field) => field.id === 'repository') + ).toMatchObject({ + type: 'selector', + selectorKey: 'github.installationRepositories', + }) + await act(async () => button('Add repository').click()) expect(mocks.create).toHaveBeenCalledWith( expect.objectContaining({ credentialId: 'github-app-credential', @@ -467,7 +533,7 @@ describe('Search methods requiring member identity', () => { expect(document.body.textContent).not.toContain('Sync using') expect(document.querySelector('button[aria-label="Choose another source"]')).toBeNull() await act(async () => - button(accessMode === 'admin' ? 'Connect & Sync' : 'Add source').click() + button(accessMode === 'admin' ? 'Connect & Sync' : 'Add Confluence site').click() ) expect(mocks.create).toHaveBeenCalledWith( expect.objectContaining({ connectorType: 'confluence', accessMode }), @@ -497,7 +563,7 @@ describe('Search methods requiring member identity', () => { }) expect(document.body.textContent).not.toContain('Sync using') expect(document.body.textContent).not.toContain('Choose another source') - await act(async () => button('Add source').click()) + await act(async () => button('Add Confluence site').click()) expect(mocks.create).toHaveBeenCalledWith( expect.objectContaining({ connectorType: 'confluence', @@ -737,7 +803,7 @@ describe('Search setup options', () => { } ) - it.each(SEARCH_SETUP_FIELDS)( + it.each(SEARCH_SETUP_FIELDS.filter(({ connectorType }) => connectorType !== 'gitlab'))( 'keeps $connectorType general knowledge-base fields and sync frequency outside document details', async ({ connectorType, primary, optional, cap }) => { await render({ @@ -757,6 +823,21 @@ describe('Search setup options', () => { } ) + it('uses GitLab service-account access and token tabs without an access selector in regular KBs', async () => { + await render({ + initialConnectorType: 'gitlab', + initialAccessMode: 'workspace', + isSearchIndex: false, + }) + expect(document.body.textContent).toContain('Administrator token') + expect(document.body.textContent).toContain('Non-admin token') + expect(document.body.textContent).not.toContain('Connection method') + expect(button('More options')).toHaveAttribute('aria-expanded', 'false') + expect(document.body.textContent).not.toContain('Sync Frequency') + await act(async () => button('More options').click()) + expect(document.body.textContent).toContain('Sync Frequency') + }) + it('keeps administrator-required fields in the primary form even if metadata marks them optional', async () => { mocks.credentials = [{ id: 'service', name: 'Indexing account', type: 'service_account' }] await render({ initialConnectorType: 'google_drive', initialAccessMode: 'admin' }) @@ -809,6 +890,32 @@ describe('Search setup options', () => { }) describe('Account connection dropdown', () => { + it.each(['jira', 'confluence'])( + 'reuses the caller’s managed %s account for browsing member sources', + async (connectorType) => { + mocks.credentials = [ + { id: 'managed-account', name: 'My Search account', type: 'managed_oauth' }, + ] + await render({ + initialConnectorType: connectorType, + lockedAccessMode: 'members', + scope: { kind: 'organization', organizationId: 'org-1' }, + }) + expect(mocks.oauthQuery).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ purpose: 'browsing', organizationId: 'org-1' }) + ) + await act(async () => combobox('My Search account').click()) + const option = Array.from(document.querySelectorAll('[role="option"]')).find( + (node) => node.textContent?.trim() === 'My Search account' + ) + expect(option).toBeDefined() + await act(async () => option!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) + expect(mocks.configFields).toHaveBeenLastCalledWith( + expect.objectContaining({ credentialId: 'managed-account' }) + ) + } + ) it.each(['google_drive', 'gmail', 'google_calendar'])( 'opens only service-account creation for a central %s source and submits that credential', async (connectorType) => { @@ -858,7 +965,10 @@ describe('Account connection dropdown', () => { ) it('only offers service accounts when creating a central Confluence source', async () => { - mocks.credentials = [{ id: 'personal-account', name: 'Personal Confluence', type: 'oauth' }] + mocks.credentials = [ + { id: 'personal-account', name: 'Personal Confluence', type: 'oauth' }, + { id: 'managed-account', name: 'My Search account', type: 'managed_oauth' }, + ] mocks.serviceAccountTarget = { serviceAccountProviderId: 'atlassian-service-account', serviceName: 'Atlassian', @@ -893,15 +1003,16 @@ describe('Account connection dropdown', () => { expect(mocks.oauthModal).not.toHaveBeenCalled() }) - it('offers GitHub indexing accounts directly without requiring a browsing credential', async () => { + it('keeps member and dedicated GitHub indexing choices in workspace Search', async () => { mocks.credentials = [] await render({ initialConnectorType: 'github', lockedAccessMode: 'members', - scope: { kind: 'organization', organizationId: 'org-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, setupDraftKey: 'github-members', }) expect(document.body.textContent).not.toContain('Account for browsing') + await act(async () => button('More options').click()) expect(document.body.textContent).toContain('Sync documents with') await act(async () => combobox('Connected members').click()) const option = Array.from(document.querySelectorAll('[role="option"]')).find( @@ -914,7 +1025,7 @@ describe('Account connection dropdown', () => { expect(mocks.oauthModal).toHaveBeenLastCalledWith( expect.objectContaining({ open: true, - organizationId: 'org-1', + workspaceId: 'workspace-1', connectorType: 'github', sourceAccess: 'members', }) @@ -958,7 +1069,7 @@ describe('Account connection dropdown', () => { }) expect(document.body.textContent).toContain('Account for browsing') - expect(button('Add source')).toBeEnabled() + expect(button('Add Confluence site')).toBeEnabled() await act(async () => combobox('Select Confluence account').click()) const options = Array.from(document.querySelectorAll('[role="option"]')) expect(options.map((option) => option.textContent?.trim())).toEqual([ @@ -996,7 +1107,7 @@ describe('Account connection dropdown', () => { await render({ initialConnectorType: 'confluence', initialAccessMode: 'admin' }) expect(document.body.textContent).toContain('Could not load accounts') - expect(document.body.textContent).not.toContain('Connect Confluence') + expect(document.body.textContent).not.toContain('Connect Confluence account') expect(button('Connect & Sync')).toBeDisabled() await act(async () => button('Try again').click()) expect(mocks.refetchCredentials).toHaveBeenCalledOnce() diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 846081bd5bf..92da9801726 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -2,8 +2,6 @@ import { useId, useState } from 'react' import { - ButtonGroup, - ButtonGroupItem, Checkbox, Chip, ChipCombobox, @@ -14,6 +12,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, type ComboboxOption, OverflowText, } from '@sim/emcn' @@ -21,12 +20,15 @@ import { ArrowLeft, ChevronDown, ChevronRight, Plus, Search } from '@sim/emcn/ic import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope' import { getIntegrationsForCredentialProvider } from '@/lib/integrations/credential-display' +import { initialConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, getServiceAccountProviderForProviderId, type OAuthProvider, } from '@/lib/oauth' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' +import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels' import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors' import { SIM_SEARCH_SYNC_INTERVAL_MINUTES } from '@/lib/sim-search/constants' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' @@ -50,10 +52,8 @@ import { connectorSyncFrequencyHint, SYNC_INTERVALS, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' -import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope' -import { GitHubInstallationModal } from '@/app/workspace/[workspaceId]/search/components/github-installation-modal' import { SettingsEmptyState, SettingsQueryErrorState, @@ -61,12 +61,19 @@ import { import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { withBrandIcon } from '@/blocks/brand-icon' import { getConnectorApiKeyConfig, isConnectorCredentialTypeAllowed } from '@/connectors/auth' +import { GitHubInstallationConnectionField } from '@/connectors/github/installation-connection-field' +import { + GitLabPermissionTabs, + GitLabPermissionUploads, +} from '@/connectors/gitlab/permission-config/fields' +import { useGitLabPermissionForm } from '@/connectors/gitlab/permission-config/use-permission-form' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types' import { useCreateConnector } from '@/hooks/queries/kb/connectors' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' import { useSourceAccounts } from '@/hooks/queries/source-accounts' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' +import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup' import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useConnectorSetupStore } from '@/stores/connector-setup/store' @@ -84,6 +91,7 @@ interface AddConnectorModalProps { isSearchIndex?: boolean initialConnectorType?: string | null initialAccessMode?: ConnectorAccessSelection['accessMode'] + lockConnectorType?: boolean /** The entry point has already chosen how this source connects. */ lockedAccessMode?: 'members' | 'admin' initialSyncIntervalMinutes?: number @@ -101,6 +109,7 @@ export function AddConnectorModal({ isSearchIndex = false, initialConnectorType, initialAccessMode = 'workspace', + lockConnectorType = false, lockedAccessMode, initialSyncIntervalMinutes = 1440, onCreated, @@ -131,14 +140,18 @@ export function AddConnectorModal({ draft?.contentCredentialId ?? null ) const [access, setAccess] = useState(() => ({ - accessMode: - lockedAccessMode ?? - draft?.accessMode ?? - (isSearchIndex && initialAccessMode === 'workspace' - ? initialType && CONNECTOR_META_REGISTRY[initialType]?.auth.mode === 'apiKey' - ? 'admin' - : 'members' - : initialAccessMode), + accessMode: initialConnectorAccessMode( + initialType ? CONNECTOR_META_REGISTRY[initialType] : undefined, + canAdmin && isSearchIndex && initialType === 'github' && scope.kind === 'organization' + ? 'members' + : (lockedAccessMode ?? + (lockConnectorType ? initialAccessMode : draft?.accessMode) ?? + (isSearchIndex && initialAccessMode === 'workspace' + ? initialType && CONNECTOR_META_REGISTRY[initialType]?.auth.mode === 'apiKey' + ? 'admin' + : 'members' + : initialAccessMode)) + ), })) const [disabledTagIds, setDisabledTagIds] = useState>( () => new Set(draft?.disabledTagIds) @@ -146,9 +159,11 @@ export function AddConnectorModal({ const [showMetadata, setShowMetadata] = useState(false) const [error, setError] = useState(null) const [showOAuthModal, setShowOAuthModal] = useState(false) - const [showServiceAccountModal, setShowServiceAccountModal] = useState(false) - const [showGitHubInstallationModal, setShowGitHubInstallationModal] = useState(false) + const [serviceAccountField, setServiceAccountField] = useState<'browsing' | 'content' | null>( + null + ) + const gitlabPermissions = useGitLabPermissionForm() const [apiKeyValue, setApiKeyValue] = useState('') const [useApiKey, setUseApiKey] = useState(!isSearchIndex) const [apiKeyFocused, setApiKeyFocused] = useState(false) @@ -163,6 +178,8 @@ export function AddConnectorModal({ const { mutate: createConnector, isPending: isCreating } = useCreateConnector() const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null + const canSetUpGitHubInstallation = + canAdmin && isSearchIndex && selectedType === 'github' && scope.kind === 'organization' const docsUrl = isSearchIndex ? connectorConfig?.searchDocsUrl : undefined const setupGuideActions = docsUrl ? [ @@ -172,6 +189,12 @@ export function AddConnectorModal({ }, ] : undefined + const searchLabels = + isSearchIndex && selectedType + ? getSearchConnectionLabels(selectedType, access.accessMode) + : undefined + const modalTitle = searchLabels?.title ?? `Configure ${connectorConfig?.name}` + const showGitLabPermissions = selectedType === 'gitlab' && access.accessMode === 'admin' const isMembersMode = access.accessMode === 'members' const apiKeyConfig = connectorConfig ? getConnectorApiKeyConfig(connectorConfig.auth) : undefined const isApiKeyMode = @@ -243,6 +266,8 @@ export function AddConnectorModal({ !serviceAccountTarget.hidden && (deploymentState === 'ready' || deploymentState === 'limited') + const browsePersonalAccounts = + isMembersMode && (selectedType === 'jira' || selectedType === 'confluence') const { data: rawCredentials = [], isLoading: credentialsLoading, @@ -251,6 +276,7 @@ export function AddConnectorModal({ refetch: refetchCredentials, } = useOAuthCredentials(connectorProviderId ?? undefined, { enabled: Boolean(connectorConfig) && !isApiKeyMode, + purpose: browsePersonalAccounts ? 'browsing' : undefined, ...owner, }) @@ -258,6 +284,7 @@ export function AddConnectorModal({ const credentials = rawCredentials.filter( (credential) => + (browsePersonalAccounts && credential.type === 'managed_oauth') || !connectorConfig || isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, credential.type) ) @@ -270,6 +297,17 @@ export function AddConnectorModal({ : credentials.length === 1 ? credentials[0].id : null + const installations = credentials.filter( + (credential) => credential.provider === GITHUB_INSTALLATION_PROVIDER_ID + ) + const installationCredentialId = contentCredentialId + ? (installations.find((credential) => credential.id === contentCredentialId)?.id ?? null) + : installations.length === 1 + ? installations[0].id + : null + const effectiveContentCredentialId = canSetUpGitHubInstallation + ? installationCredentialId + : contentCredentialId const { sourceConfig, @@ -293,10 +331,21 @@ export function AddConnectorModal({ initialSelectionLabels: draft?.selectionLabels, }) + const githubSetup = useGitHubInstallationSetup({ + organizationId: + canSetUpGitHubInstallation && scope.kind === 'organization' + ? scope.organizationId + : undefined, + onConnected: (credentialId) => { + if (credentialId !== installationCredentialId) handleFieldChange('repository', '') + setContentCredentialId(credentialId) + }, + }) + const indexingCredentialId = isApiKeyMode ? null : isMembersMode - ? contentCredentialId + ? effectiveContentCredentialId : effectiveCredentialId const indexingCredential = credentials.find( (credential) => credential.id === indexingCredentialId @@ -308,10 +357,11 @@ export function AddConnectorModal({ indexingCredential?.type === 'service_account') const showCredentialPicker = - !isMembersMode || - connectorConfig?.configFields.some( - (field) => field.type === 'selector' && isFieldVisible(field) - ) + !canSetUpGitHubInstallation && + (!isMembersMode || + connectorConfig?.configFields.some( + (field) => field.type === 'selector' && isFieldVisible(field) + )) const saveSetup = () => { if (!setupDraftKey) return @@ -321,7 +371,7 @@ export function AddConnectorModal({ canonicalModes, accessMode: access.accessMode, credentialId: effectiveCredentialId, - contentCredentialId, + contentCredentialId: effectiveContentCredentialId, disabledTagIds: Array.from(disabledTagIds), savedAt: Date.now(), }) @@ -333,7 +383,7 @@ export function AddConnectorModal({ } const isOptionalSetupField = (field: ConnectorConfigField) => - isSearchIndex && + (isSearchIndex || connectorConfig?.supportedAccessModes?.length === 1) && field.setupGroup === 'options' && Boolean(connectorConfig && !isConnectorFieldRequired(field, connectorConfig, access.accessMode)) const hasOptionalSetupFields = connectorConfig?.configFields.some( @@ -345,10 +395,26 @@ export function AddConnectorModal({ ? { scope, accessMode: access.accessMode, - connectorConfig, + connectorConfig: canSetUpGitHubInstallation + ? { + ...connectorConfig, + configFields: connectorConfig.configFields.map((field) => + field.id === 'repository' + ? { + ...field, + type: 'selector', + selectorKey: 'github.installationRepositories', + placeholder: 'Select a repository', + } + : field + ), + } + : connectorConfig, sourceConfig, selectionLabels, - credentialId: effectiveCredentialId, + credentialId: canSetUpGitHubInstallation + ? installationCredentialId + : effectiveCredentialId, credentialType: credentials.find((item) => item.id === effectiveCredentialId)?.type, canonicalGroups, canonicalModes, @@ -358,10 +424,10 @@ export function AddConnectorModal({ } : null - const canSetUpGitHubInstallation = - canAdmin && isSearchIndex && selectedType === 'github' && scope.kind === 'organization' const contentCredentialField = - isMembersMode && connectorConfig?.supportsSeparateContentCredential ? ( + !canSetUpGitHubInstallation && + isMembersMode && + connectorConfig?.supportsSeparateContentCredential ? ( <> setShowServiceAccountModal(true), - }, - ] - : []), - ...(canSetUpGitHubInstallation - ? [ - { - value: '__github_installation__', - label: 'Connect GitHub App', - icon: Plus, - onSelect: () => setShowGitHubInstallationModal(true), + onSelect: () => setServiceAccountField('content'), }, ] : []), @@ -419,24 +475,32 @@ export function AddConnectorModal({ const closeSetup = (nextOpen: boolean) => { if (!nextOpen && setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey) + if (!nextOpen) { + gitlabPermissions.reset() + setApiKeyValue('') + } onOpenChange(nextOpen) } const handleSelectType = (type: string) => { if (setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey) + gitlabPermissions.reset() setSelectedType(type) setSourceConfig( isSearchIndex ? { ...CONNECTOR_META_REGISTRY[type]?.searchDefaultSourceConfig } : {} ) setSelectedCredentialId(null) setContentCredentialId(null) - setAccess( - isSearchIndex - ? { - accessMode: CONNECTOR_META_REGISTRY[type]?.auth.mode === 'apiKey' ? 'admin' : 'members', - } - : WORKSPACE_ACCESS - ) + setAccess({ + accessMode: initialConnectorAccessMode( + CONNECTOR_META_REGISTRY[type], + isSearchIndex + ? CONNECTOR_META_REGISTRY[type]?.auth.mode === 'apiKey' + ? 'admin' + : 'members' + : 'workspace' + ), + }) setApiKeyValue('') setUseApiKey(!isSearchIndex) setApiKeyFocused(false) @@ -451,7 +515,9 @@ export function AddConnectorModal({ const hasRequiredCredential = isApiKeyMode ? isApiKeyOptional || Boolean(apiKeyValue.trim()) - : isMembersMode || Boolean(effectiveCredentialId) + : canSetUpGitHubInstallation + ? Boolean(installationCredentialId) + : isMembersMode || Boolean(effectiveCredentialId) const hasSearchAccess = !isSearchIndex || Boolean( @@ -463,6 +529,7 @@ export function AddConnectorModal({ const canSubmit = Boolean( connectorConfig && hasRequiredCredential && + (!showGitLabPermissions || gitlabPermissions.complete) && hasSearchAccess && (access.accessMode !== 'admin' || allowAdmin) && (!isMembersMode || allowMembers) && @@ -504,6 +571,7 @@ export function AddConnectorModal({ { knowledgeBaseId, connectorType: selectedType, + ...(showGitLabPermissions ? { permissionConfig: gitlabPermissions.input } : {}), accessMode: access.accessMode, ...(isApiKeyMode ? apiKeyValue.trim() @@ -512,7 +580,7 @@ export function AddConnectorModal({ : isMembersMode ? { accessMode: 'members' as const, - credentialId: contentCredentialId ?? undefined, + credentialId: effectiveContentCredentialId ?? undefined, } : { accessMode: access.accessMode, credentialId: effectiveCredentialId! }), sourceConfig: finalSourceConfig, @@ -547,14 +615,14 @@ export function AddConnectorModal({ closeSetup(false)}> {step === 'configure' ? ( - {!lockedAccessMode && ( + {!lockConnectorType && !lockedAccessMode && ( )} - {`Configure ${connectorConfig?.name}`} + {modalTitle} ) : ( - 'Connect Source' + 'Add source' )} @@ -611,6 +679,9 @@ export function AddConnectorModal({
) : connectorConfig ? ( <> + {showGitLabPermissions && ( + + )} {integrationAvailabilityError && ( )} - {(!lockedAccessMode || slackSetupRequired) && + {!canSetUpGitHubInstallation && + (!lockedAccessMode || slackSetupRequired) && (memberAccessAvailable || mirroredAccessAvailable || slackSetupRequired) && ( {credentialsError && rawCredentials.length === 0 ? ( @@ -728,7 +793,7 @@ export function AddConnectorModal({ label: serviceAccountTarget.label, value: '__service_account__', icon: Plus, - onSelect: () => setShowServiceAccountModal(true), + onSelect: () => setServiceAccountField('browsing'), }, ] : []), @@ -750,7 +815,29 @@ export function AddConnectorModal({ ) : null} - {(!isSearchIndex || canSetUpGitHubInstallation) && contentCredentialField} + {canSetUpGitHubInstallation && ( + void refetchCredentials()} + onConnect={() => + void githubSetup.connect(installations.length ? 'install' : undefined) + } + connecting={githubSetup.pending} + connectionError={githubSetup.error} + onCancel={githubSetup.cancel} + onChange={(credentialId) => { + if (credentialId !== installationCredentialId) + handleFieldChange('repository', '') + setContentCredentialId(credentialId) + }} + disabled={isCreating} + /> + )} + {!isSearchIndex && contentCredentialField} {configFieldsProps && ( )} + {showGitLabPermissions && ( + + )} {(hasOptionalSetupFields || contentCredentialField || @@ -774,7 +864,9 @@ export function AddConnectorModal({ aria-expanded={showMetadata} onClick={() => setShowMetadata((visible) => !visible)} > - {isSearchIndex ? 'More options' : 'Document details (optional)'} + {isSearchIndex || hasOptionalSetupFields + ? 'More options' + : 'Document details (optional)'}
{showMetadata && ( @@ -829,31 +921,35 @@ export function AddConnectorModal({ )} - {!isSearchIndex && ( + {!isSearchIndex && (!hasOptionalSetupFields || showMetadata) && ( - setSyncInterval(Number(val))} - > - {SYNC_INTERVALS.map((interval) => ( - - {interval.label} - {interval.requiresMax && !hasMaxAccess && } - - ))} - + onChange={(value) => setSyncInterval(Number(value))} + options={SYNC_INTERVALS.map((interval) => ({ + value: String(interval.value), + label: + interval.requiresMax && !hasMaxAccess + ? `${interval.label} (Max)` + : interval.label, + disabled: interval.requiresMax && !hasMaxAccess, + }))} + /> )} @@ -890,19 +986,21 @@ export function AddConnectorModal({ : 'Connecting…' : isMembersMode ? scope.kind === 'organization' - ? 'Add source' + ? (searchLabels?.add ?? 'Add connection') : 'Create & Invite' : 'Connect & Sync', onClick: handleSubmit, - disabled: !canSubmit || isCreating, + disabled: !canSubmit || isCreating || githubSetup.pending, }} /> ))} - {showServiceAccountModal && canConnectServiceAccount && ( + {serviceAccountField && canConnectServiceAccount && ( { + if (!open) setServiceAccountField(null) + }} {...owner} serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId} serviceName={serviceAccountTarget.serviceName} @@ -913,23 +1011,11 @@ export function AddConnectorModal({ ? `${docsUrl}#using-a-service-account` : undefined } - onCreated={setSelectedCredentialId} + onCreated={ + serviceAccountField === 'content' ? setContentCredentialId : setSelectedCredentialId + } /> )} - {showGitHubInstallationModal && - canSetUpGitHubInstallation && - isMembersMode && - scope.kind === 'organization' && ( - setShowGitHubInstallationModal(false)} - onConnected={(credentialId) => { - setContentCredentialId(credentialId) - setShowGitHubInstallationModal(false) - }} - /> - )} {showOAuthModal && connectorConfig && connectorConfig.auth.mode === 'oauth' && @@ -951,7 +1037,7 @@ export function AddConnectorModal({ {...owner} knowledgeBaseId={knowledgeBaseId} connectorType={selectedType ?? undefined} - sourceAccess={lockedAccessMode === 'members' ? 'members' : undefined} + sourceAccess={access.accessMode === 'members' ? 'members' : undefined} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx index 3c23dda2671..70ca5117317 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx @@ -124,7 +124,7 @@ describe('connection method selection', () => { ) expect(dropdown).toBeDisabled() expect(dropdown).toHaveTextContent(label) - expect(container.textContent).toContain('Create a new source to change the sync method.') + expect(container.textContent).toContain('Add a new connection to change the sync method.') expect(container.querySelector('[role="radiogroup"]')).toBeNull() await act(async () => dropdown!.click()) expect(document.querySelector('[role="menu"]')).toBeNull() @@ -155,10 +155,20 @@ describe('connection method selection', () => { expect(onChange).toHaveBeenCalledWith({ accessMode: 'admin' }) }) - it('summarizes a single supported method without a selector', async () => { + it('omits the field when the connector supports only the selected method', async () => { await render({ connectorConfig: gitlabConnectorMeta, value: { accessMode: 'admin' } }) expect(container.querySelector('[role="radiogroup"]')).toBeNull() - expect(container.textContent).toContain('Admin or service account') + expect(container.textContent).toBe('') + }) + + it('keeps pending upgrade actions without restoring the redundant selector', async () => { + await render({ + connectorConfig: gitlabConnectorMeta, + value: { accessMode: 'admin' }, + footer: , + }) + expect(container.querySelector('[role="radiogroup"]')).toBeNull() + expect(container.querySelector('button')?.textContent).toBe('Apply changes') }) it('shows ordinary members a summary without editable or disabled choices', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx index 1f355ac085e..79507a3e70b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from 'react' import { - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipCombobox, ChipDropdown, ChipLink, @@ -12,6 +12,7 @@ import { } from '@sim/emcn' import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { supportsConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { slackSearchSetupHref } from '@/lib/sim-search/setup-navigation' import { connectorMemberProvider } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access' import { @@ -135,6 +136,13 @@ export function ConnectorAccessField({ allowed: adminSupported && allowAdmin, }, ] + for (const entry of modes) + entry.allowed &&= supportsConnectorAccessMode(connectorConfig, entry.mode) + if ( + connectorConfig.supportedAccessModes?.length === 1 && + modes.some((entry) => entry.mode === value.accessMode && entry.allowed) + ) + return canAdmin && footer ?
{footer}
: null /** Keep a retired current method visible so an admin can select an available replacement. */ const visibleModes = modes.filter((entry) => entry.allowed || entry.mode === value.accessMode) const currentMode = modes.find((entry) => entry.mode === value.accessMode) @@ -152,7 +160,7 @@ export function ConnectorAccessField({ canAdmin && isAvailabilityReady && !currentMode?.allowed ? `This connection method is not available in this ${scope.kind}.` : lockAccessMode - ? 'Create a new source to change the sync method.' + ? 'Add a new connection to change the sync method.' : value.accessMode === 'workspace' ? 'Everyone in this workspace can search these documents.' : undefined @@ -168,7 +176,7 @@ export function ConnectorAccessField({ className='w-fit' /> ) : showModeSelector ? ( - { @@ -177,11 +185,11 @@ export function ConnectorAccessField({ }} > {visibleModes.map((entry) => ( - + {entry.label} - + ))} - + ) : (

{modes.find((entry) => entry.mode === value.accessMode)?.label} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx index ceb23481aa2..a63cf633a05 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx @@ -4,6 +4,7 @@ import { Chip, ChipDropdown, ChipInput, ChipLink, Skeleton } from '@sim/emcn' import { RefreshCw, Search, SquareArrowUpRight } from '@sim/emcn/icons' import type { ConnectorDocumentFilter } from '@/lib/api/contracts/knowledge/connectors' import type { ResourceScope } from '@/lib/core/resource-scope' +import { getDocumentIndexingStatus } from '@/lib/knowledge/documents/types' import { SettingsEmptyState, SettingsQueryErrorState, @@ -59,9 +60,10 @@ export function ConnectorDocuments({ const documents = (data?.pages.flatMap((page) => page.documents) ?? []).filter((document) => filter === 'excluded' ? document.userExcluded - : !document.userExcluded && (filter !== 'failed' || document.processingStatus === 'failed') + : !document.userExcluded && + (filter === 'active' || getDocumentIndexingStatus(document) === filter) ) - const counts = data?.pages[0]?.counts ?? { active: 0, excluded: 0, failed: 0 } + const counts = data?.pages[0]?.counts ?? { active: 0, excluded: 0, failed: 0, skipped: 0 } const visibleDocumentCount = counts[filter] const hasMoreVisibleDocuments = Boolean(hasNextPage && documents.length < visibleDocumentCount) @@ -92,7 +94,12 @@ export function ConnectorDocuments({ aria-label='Document status' value={filter} onChange={(value) => { - if (value === 'active' || value === 'excluded' || value === 'failed') + if ( + value === 'active' || + value === 'excluded' || + value === 'failed' || + value === 'skipped' + ) onFilterChange(value) }} matchTriggerWidth={false} @@ -103,6 +110,7 @@ export function ConnectorDocuments({ label: isLoading ? 'Excluded' : `Excluded (${counts.excluded})`, }, { value: 'failed', label: isLoading ? 'Failed' : `Failed (${counts.failed})` }, + { value: 'skipped', label: isLoading ? 'Skipped' : `Skipped (${counts.skipped})` }, ]} />

@@ -128,7 +136,9 @@ export function ConnectorDocuments({ ? 'No excluded documents' : filter === 'failed' ? 'No failed documents' - : 'No documents yet'} + : filter === 'skipped' + ? 'No skipped documents' + : 'No documents yet'} ) : ( documents.map((doc) => ( @@ -136,13 +146,17 @@ export function ConnectorDocuments({ key={doc.id} title={doc.filename} description={ - doc.processingStatus === 'failed' + getDocumentIndexingStatus(doc) === 'failed' ? 'Indexing failed' - : doc.processingStatus === 'pending' - ? 'Waiting to index' - : doc.processingStatus === 'processing' - ? 'Indexing' - : undefined + : getDocumentIndexingStatus(doc) === 'skipped' + ? doc.processingError + ? `Skipped · ${doc.processingError}` + : 'Skipped' + : doc.processingStatus === 'pending' + ? 'Waiting to index' + : doc.processingStatus === 'processing' + ? 'Indexing' + : undefined } trailing={
@@ -155,7 +169,7 @@ export function ConnectorDocuments({ aria-label={`Open ${doc.filename}`} /> )} - {doc.processingStatus === 'failed' && !doc.userExcluded && ( + {getDocumentIndexingStatus(doc) === 'failed' && !doc.userExcluded && ( { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.queries.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.queries.test.tsx index d4bb2cd6abf..9958d302243 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.queries.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.queries.test.tsx @@ -7,10 +7,11 @@ import type { SelectorRequest, SelectorResult } from '@/lib/selectors/types' import type { ConnectorConfigField } from '@/connectors/types' interface ComboboxProps { - options: { value: string; label: string }[] + options: { value: string; label: string; disabled?: boolean; onSelect?: () => void }[] isLoading: boolean onChange: (value: string) => void onMultiSelectChange?: (value: string[]) => void + onSearchChange?: (value: string) => void } const mocks = vi.hoisted(() => ({ @@ -18,7 +19,9 @@ const mocks = vi.hoisted(() => ({ combobox: vi.fn((_props: ComboboxProps) => null), })) -vi.mock('@sim/emcn', () => ({ ChipCombobox: mocks.combobox })) +vi.mock('@sim/emcn', () => ({ + ChipCombobox: mocks.combobox, +})) vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) vi.mock('@/hooks/use-debounce', () => ({ useDebounce: (value: string) => value })) vi.mock('@/lib/selectors/client/execute-selector', () => ({ @@ -161,3 +164,171 @@ it('keeps the loaded list visible while resolving a saved selection on another p client.clear() } }) + +async function renderBulkSelector() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const change = vi.fn() + const rerender = (sourceConfig = { domain: 'example.atlassian.net' }) => + act(async () => + root.render( + + + + ) + ) + await rerender() + const all = () => { + const option = mocks.combobox.mock.lastCall![0].options.find((item) => item.label === 'All') + if (!option) throw new Error('Missing All option') + return option + } + await act(async () => vi.waitFor(() => expect(all().disabled).toBe(false), { interval: 1 })) + return { + container, + change, + all, + rerender, + dispose: async () => { + await act(async () => root.unmount()) + client.clear() + container.remove() + }, + } +} + +it('selects every complete page from All without an external toolbar', async () => { + mocks.execute.mockImplementation(async ({ request }: { request: SelectorRequest }) => + request.kind === 'detail' + ? { kind: 'detail', item: null } + : request.cursor + ? { kind: 'list', items: [options[1], options[0]] } + : { kind: 'list', items: [options[0]], nextCursor: 'next' } + ) + const view = await renderBulkSelector() + try { + expect(view.container.textContent).not.toContain('Select all') + await act(async () => view.all().onSelect?.()) + await act(async () => + vi.waitFor(() => expect(view.change).toHaveBeenCalledWith(['ENG', 'OPS'], options), { + interval: 1, + }) + ) + expect(mocks.execute.mock.calls.filter(([args]) => args.request.kind === 'list')).toHaveLength( + 2 + ) + await act(async () => mocks.combobox.mock.lastCall![0].onMultiSelectChange?.([])) + expect(view.change).toHaveBeenLastCalledWith([], []) + } finally { + await view.dispose() + } +}) + +it.each(['partial', 'error'] as const)('preserves the selection on %s results', async (failure) => { + mocks.execute.mockImplementation(async ({ request }: { request: SelectorRequest }) => { + if (request.kind === 'detail') return { kind: 'detail', item: null } + if (request.cursor) { + if (failure === 'error') throw new Error('Provider is unavailable') + return { kind: 'list', items: [options[1]], truncated: true } + } + return { kind: 'list', items: [options[0]], nextCursor: 'next' } + }) + const view = await renderBulkSelector() + try { + await act(async () => view.all().onSelect?.()) + await act(async () => + vi.waitFor(() => expect(view.container.querySelector('[role="alert"]')).not.toBeNull(), { + interval: 1, + }) + ) + await view.rerender() + expect(view.container.querySelector('[role="alert"]')).not.toBeNull() + expect(view.change).not.toHaveBeenCalled() + expect(view.container.textContent).not.toContain('Select all') + expect(view.container.textContent).toContain( + failure === 'error' ? 'Could not load all options' : 'too many results' + ) + await view.rerender({ domain: 'another.atlassian.net' }) + expect(view.container.querySelector('[role="alert"]')).toBeNull() + } finally { + await view.dispose() + } +}) + +it('disables bulk selection while searching and ignores a completion after the user edits selection', async () => { + let resolvePage!: (result: SelectorResult) => void + const pending = new Promise((resolve) => { + resolvePage = resolve + }) + mocks.execute.mockImplementation(({ request }: { request: SelectorRequest }) => + request.kind === 'detail' + ? Promise.resolve({ kind: 'detail', item: null }) + : request.cursor + ? pending + : Promise.resolve({ kind: 'list', items: [options[0]], nextCursor: 'next' }) + ) + const view = await renderBulkSelector() + try { + await act(async () => mocks.combobox.mock.lastCall![0].onSearchChange?.('Eng')) + expect(view.all().disabled).toBe(true) + await act(async () => mocks.combobox.mock.lastCall![0].onSearchChange?.('')) + await act(async () => view.all().onSelect?.()) + await act(async () => mocks.combobox.mock.lastCall![0].onMultiSelectChange?.([])) + expect(view.change).toHaveBeenCalledTimes(1) + await act(async () => resolvePage({ kind: 'list', items: [options[1]] })) + await act(async () => + vi.waitFor(() => expect(view.all().disabled).toBe(false), { + interval: 1, + }) + ) + expect(view.change).toHaveBeenCalledTimes(1) + expect(view.change).toHaveBeenLastCalledWith([], []) + } finally { + await view.dispose() + } +}) + +it('hydrates only the trigger-visible labels for large saved selections', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const root = createRoot(document.createElement('div')) + try { + await act(async () => + root.render( + + `SAVED-${index}`)} + onChange={vi.fn()} + credentialId='credential-1' + sourceConfig={{ domain: 'example.atlassian.net' }} + configFields={[domainField, field]} + canonicalModes={{}} + /> + + ) + ) + await act(async () => + vi.waitFor(() => expect(mocks.combobox.mock.lastCall![0].isLoading).toBe(false), { + interval: 1, + }) + ) + expect( + mocks.execute.mock.calls + .filter(([args]) => args.request.kind === 'detail') + .map(([args]) => args.request.id) + ).toEqual(['SAVED-0', 'SAVED-1']) + } finally { + await act(async () => root.unmount()) + client.clear() + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.test.tsx index a9746766ade..716589573d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.test.tsx @@ -8,9 +8,12 @@ const mocks = vi.hoisted(() => ({ combobox: vi.fn((_props: ComboboxCallbacks) => null), change: vi.fn(), projectContext: vi.fn((_key: string, context: Record) => context), + loadAll: vi.fn(), })) -vi.mock('@sim/emcn', () => ({ ChipCombobox: mocks.combobox })) +vi.mock('@sim/emcn', () => ({ + ChipCombobox: mocks.combobox, +})) vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) vi.mock('@/lib/selectors/context', () => ({ projectSelectorContext: mocks.projectContext })) vi.mock('@/lib/selectors/manifest', () => ({ @@ -22,6 +25,7 @@ vi.mock('@/hooks/queries/selectors', () => ({ data: [{ id: 'folder-b', label: 'Company docs', secret: 'not display metadata' }], error: null, truncated: false, + loadAll: mocks.loadAll, }), useSelectorOptionDetails: () => ({ data: [{ id: 'folder-a', label: 'Engineering' }] }), useSelectorOptionDetail: () => ({}), @@ -30,7 +34,7 @@ vi.mock('@/hooks/queries/selectors', () => ({ import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field' interface ComboboxCallbacks { - options: { value: string; label: string; hidden?: boolean }[] + options: { value: string; label: string; hidden?: boolean; onSelect?: () => void }[] disabled: boolean onChange?: (value: string) => void onMultiSelectChange?: (value: string[]) => void @@ -173,3 +177,55 @@ it('uses saved labels only for selected values and prefers live provider names', vi.clearAllMocks() } }) + +it('keeps the prior selection when all personal setup options exceed its source limit', async () => { + mocks.loadAll.mockResolvedValue({ + status: 'complete', + options: Array.from({ length: 1001 }, (_, index) => ({ + id: `P${index}`, + label: `Project ${index}`, + })), + }) + const field: ConnectorConfigField & { selectorKey: 'jira.projectKeys' } = { + id: 'projects', + title: 'Projects', + type: 'selector', + selectorKey: 'jira.projectKeys', + multi: true, + allowSelectAll: true, + } + const container = document.createElement('div') + const root = createRoot(container) + try { + await act(async () => + root.render( + + ) + ) + const all = mocks.combobox.mock.lastCall![0].options.find((item) => item.label === 'All') + expect(container.textContent).not.toContain('Select all') + await act(async () => all?.onSelect?.()) + expect(mocks.loadAll).toHaveBeenCalledTimes(1) + expect(mocks.change).not.toHaveBeenCalled() + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + 'Select up to 1,000 items' + ) + } finally { + await act(async () => root.unmount()) + vi.clearAllMocks() + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 8a02e6a09d1..bdd170f7e50 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -1,12 +1,14 @@ 'use client' -import { useMemo, useState } from 'react' -import { ChipCombobox, type ComboboxOption } from '@sim/emcn' +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChipCombobox, type ChipModalFieldAria, type ComboboxOption } from '@sim/emcn' +import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { projectSelectorContext } from '@/lib/selectors/context' import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' -import type { SelectorContext } from '@/lib/selectors/types' +import type { SelectorContext, SelectorSurface } from '@/lib/selectors/types' +import { MAX_PERSONAL_SOURCE_SETUP_KEYS } from '@/lib/sim-search/personal-source-setup' import type { SourceSelectionLabel } from '@/lib/sim-search/source-identity' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' @@ -23,7 +25,9 @@ import { import { useDebounce } from '@/hooks/use-debounce' interface ConnectorSelectorFieldProps { + controlAria?: ChipModalFieldAria scope?: ResourceScope + selectorSurface?: SelectorSurface field: ConnectorConfigField & { selectorKey: SelectorKey } value: ConfigFieldValue onChange: (value: ConfigFieldValue, selectedOptions?: SourceSelectionLabel[]) => void @@ -37,7 +41,9 @@ interface ConnectorSelectorFieldProps { } export function ConnectorSelectorField({ + controlAria, scope: explicitScope, + selectorSurface, field, value, onChange, @@ -53,6 +59,17 @@ export function ConnectorSelectorField({ const scope = explicitScope ?? resourceScopeFromOwner(params) const isMulti = Boolean(field.multi) const [searchTerm, setSearchTerm] = useState('') + const [bulkError, setBulkError] = useState<{ context: SelectorContext; message: string } | null>( + null + ) + const bulkGenerationRef = useRef(0) + + useEffect( + () => () => { + bulkGenerationRef.current += 1 + }, + [] + ) const context = useMemo(() => { const candidate: Record = {} @@ -95,20 +112,26 @@ export function ConnectorSelectorField({ }, [field.dependsOn, sourceConfig, configFields, canonicalModes]) const isEnabled = !disabled && !!credentialId && depsResolved + const missingDependencyMessage = selectorSurface + ? 'Enter your Atlassian site first' + : `Select ${getDependencyLabel(field, configFields)} first` const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) const { data: options = [], isLoading, + isFetching, hasMore, isFetchingMore, isLoadingAll, truncated, loadMore, loadAll, + refetch, error, } = useSelectorOptions(field.selectorKey, { context, scope, + surface: selectorSurface, search: debouncedSearch, enabled: isEnabled, surfaceId: `connector:${field.id}`, @@ -121,13 +144,15 @@ export function ConnectorSelectorField({ ) const missingSelectedIds = useMemo(() => { const loadedIds = new Set(options.map((option) => option.id)) - return selectedIds.filter((id) => !loadedIds.has(id)) + /** The trigger displays at most two labels; additional selections are counted. */ + return selectedIds.slice(0, 2).filter((id) => !loadedIds.has(id)) }, [options, selectedIds]) const { data: selectedOptions, isLoading: isLoadingSelectedOptions } = useSelectorOptionDetails( field.selectorKey, { context, scope, + surface: selectorSurface, detailIds: isEnabled ? missingSelectedIds : [], surfaceId: `connector:${field.id}`, } @@ -143,6 +168,7 @@ export function ConnectorSelectorField({ const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, { context, scope, + surface: selectorSurface, detailId: resolvesUnknownIds && isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, surfaceId: `connector:${field.id}`, @@ -171,6 +197,8 @@ export function ConnectorSelectorField({ }, [options, selectedOptions, searchedOption, selectedLabels, selectedIds]) const handleChange = (nextValue: ConfigFieldValue) => { + bulkGenerationRef.current += 1 + setBulkError(null) const ids = new Set(Array.isArray(nextValue) ? nextValue : nextValue ? [nextValue] : []) const selected = comboboxOptions .filter((option) => ids.has(option.value)) @@ -178,61 +206,138 @@ export function ConnectorSelectorField({ onChange(nextValue, selected) } + const handleSearchChange = (nextSearch: string) => { + bulkGenerationRef.current += 1 + setBulkError(null) + setSearchTerm(nextSearch) + } + + const hasSearch = searchTerm.trim().length > 0 || debouncedSearch.length > 0 + const selectedIdSet = new Set(selectedIds) + const allSelected = + !hasMore && + !truncated && + options.length > 0 && + selectedIds.length === options.length && + options.every((option) => selectedIdSet.has(option.id)) + const selectAll = async () => { + if (!isEnabled || hasSearch || isFetching || isLoadingAll) return + if (allSelected) { + handleChange([]) + return + } + const generation = ++bulkGenerationRef.current + setBulkError(null) + const result = await loadAll() + if (bulkGenerationRef.current !== generation || result.status === 'cancelled') return + if (result.status !== 'complete') { + setBulkError({ + context, + message: + result.status === 'partial' + ? 'There are too many results to select all. Select items individually or enter keys.' + : 'Could not load all options. Try again.', + }) + return + } + if ( + selectorSurface?.kind === 'personal-search-setup' && + result.options.length > MAX_PERSONAL_SOURCE_SETUP_KEYS + ) { + setBulkError({ + context, + message: `Select up to ${MAX_PERSONAL_SOURCE_SETUP_KEYS.toLocaleString()} items. Choose a smaller set to continue.`, + }) + return + } + onChange( + result.options.map((option) => option.id), + result.options.map((option) => ({ id: option.id, label: option.label })) + ) + } + if (isMulti) { const multiValues = Array.isArray(value) ? value : value ? [value] : [] return ( - +
+ 0 || hasMore) + ? [ + { + value: '', + label: 'All', + disabled: !isEnabled || hasSearch || isFetching || isLoadingAll, + onSelect: () => void selectAll(), + keepOpen: true, + selected: allSelected, + }, + ...comboboxOptions, + ] + : comboboxOptions + } + multiSelectValues={multiValues} + onMultiSelectChange={handleChange} + searchable + onSearchChange={handleSearchChange} + searchPlaceholder={`Search ${field.title.toLowerCase()}...`} + placeholder={ + !credentialId + ? 'Connect an account first' + : !depsResolved + ? missingDependencyMessage + : field.placeholder || `Select ${field.title.toLowerCase()}` + } + disabled={disabled || !credentialId || !depsResolved} + isLoading={isEnabled && (isLoading || (options.length === 0 && isLoadingSelectedOptions))} + hasMore={hasMore || Boolean(error)} + isLoadingMore={isFetchingMore} + isLoadingAll={isLoadingAll} + truncated={truncated} + onLoadMore={error ? refetch : loadMore} + onLoadAll={error ? refetch : loadAll} + emptyMessage={emptyMessage} + error={error?.message} + /> + {bulkError && isEqual(bulkError.context, context) && ( +

+ {bulkError.message} +

+ )} +
) } return ( ) } @@ -244,7 +349,7 @@ function getEmptyMessage( truncated: boolean } ): string { - if (state.error) return 'No match — the list failed to load. Try reopening' + if (state.error) return 'Could not load options. Try again.' if (state.truncated) return 'No match — too many to list. Try a more exact term' return `No ${noun} found` } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.ui.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.ui.test.tsx new file mode 100644 index 00000000000..2f966afe39d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.ui.test.tsx @@ -0,0 +1,179 @@ +/** @vitest-environment jsdom */ +import { act, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadAll: vi.fn(), + change: vi.fn(), + hasMore: true, + options: null as { id: string; label: string }[] | null, + error: null as Error | null, + refetch: vi.fn(), + loadMore: vi.fn(), +})) +vi.mock('next/navigation', () => ({ useParams: () => ({ organizationId: 'org-1' }) })) +vi.mock('@/hooks/queries/selectors', () => ({ + useSelectorOptions: () => ({ + data: + mocks.options ?? + (mocks.hasMore + ? [{ id: 'ENG', label: 'Engineering' }] + : [ + { id: 'ENG', label: 'Engineering' }, + { id: 'OPS', label: 'Operations' }, + ]), + hasMore: mocks.hasMore, + isLoading: false, + isFetching: false, + isLoadingAll: false, + truncated: false, + loadAll: mocks.loadAll, + loadMore: mocks.loadMore, + refetch: mocks.refetch, + error: mocks.error, + }), + useSelectorOptionDetails: () => ({ data: [] }), + useSelectorOptionDetail: () => ({}), +})) + +import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field' + +function ControlledSelector() { + const [value, setValue] = useState([]) + return ( + { + mocks.change(next, labels) + mocks.hasMore = false + setValue(Array.isArray(next) ? next : []) + }} + sourceConfig={{}} + configFields={[]} + canonicalModes={{}} + /> + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.hasMore = true + mocks.options = null + mocks.error = null +}) + +it('selects all pages with the keyboard, announces selection, and toggles it off', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const originalScroll = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = vi.fn() + mocks.loadAll.mockResolvedValue({ + status: 'complete', + options: [ + { id: 'ENG', label: 'Engineering' }, + { id: 'OPS', label: 'Operations' }, + ], + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + try { + await act(async () => root.render()) + expect(container.textContent).not.toContain('Select all') + expect(container.textContent).not.toContain('Clear') + const trigger = container.querySelector('[role="combobox"]') + expect(trigger).not.toBeNull() + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + expect(document.querySelector('[role="option"]')?.textContent).toBe('All') + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(document.querySelector('[role="option"]')?.getAttribute('aria-selected')).toBe('true') + expect(mocks.loadAll).toHaveBeenCalledOnce() + expect(mocks.change).toHaveBeenCalledWith( + ['ENG', 'OPS'], + [ + { id: 'ENG', label: 'Engineering' }, + { id: 'OPS', label: 'Operations' }, + ] + ) + await act(async () => + document + .querySelector('[role="option"]') + ?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + ) + expect(mocks.change).toHaveBeenLastCalledWith([], []) + expect(document.querySelector('[role="option"]')?.getAttribute('aria-selected')).toBe('false') + expect(mocks.loadAll).toHaveBeenCalledOnce() + } finally { + await act(async () => root.unmount()) + container.remove() + HTMLElement.prototype.scrollIntoView = originalScroll + vi.unstubAllGlobals() + } +}) + +it.each(['empty', 'error'] as const)( + 'shows the initial %s state without All and allows failed lists to retry', + async (state) => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const originalScroll = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = vi.fn() + mocks.options = [] + mocks.hasMore = false + mocks.error = state === 'error' ? new Error('Provider unavailable') : null + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + try { + await act(async () => root.render()) + const trigger = container.querySelector('[role="combobox"]') + expect(trigger).not.toBeNull() + await act(async () => trigger?.click()) + expect(document.querySelector('[role="listbox"]')).not.toBeNull() + expect(document.querySelector('[role="option"]')).toBeNull() + expect(mocks.loadAll).not.toHaveBeenCalled() + + const retry = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Try again' + ) + if (state === 'empty') { + expect(document.body.textContent).toContain('No spaces found') + expect(retry).toBeUndefined() + return + } + expect(document.body.textContent).toContain('Provider unavailable') + expect(retry).toBeDefined() + await act(async () => retry?.click()) + expect(mocks.refetch).toHaveBeenCalledOnce() + expect(mocks.loadMore).not.toHaveBeenCalled() + mocks.error = null + mocks.options = [{ id: 'ENG', label: 'Engineering' }] + await act(async () => root.render()) + expect( + Array.from(document.querySelectorAll('[role="option"]')).map((option) => option.textContent) + ).toEqual(['All', 'Engineering']) + expect( + Array.from(document.querySelectorAll('button')).some( + (button) => button.textContent?.trim() === 'Try again' + ) + ).toBe(false) + } finally { + await act(async () => root.unmount()) + container.remove() + HTMLElement.prototype.scrollIntoView = originalScroll + vi.unstubAllGlobals() + } + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx index cc2e15757d1..7e59ac05ffc 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx @@ -1,19 +1,8 @@ 'use client' import { useId } from 'react' -import { - Checkbox, - Chip, - ChipConfirmModal, - ChipModalError, - ChipModalField, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@sim/emcn' -import { MoreHorizontal } from '@sim/emcn/icons' -import { SettingsActionChip } from '@/components/settings/settings-header' +import { Checkbox, ChipConfirmModal, ChipModalError, ChipModalField } from '@sim/emcn' +import { SettingsActionChips } from '@/components/settings/settings-header' import { type ConnectorActionState, type ConnectorActionsOptions, @@ -24,31 +13,10 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo export function ConnectorActions(props: ConnectorActionsOptions) { const state = useConnectorActions(props) if (!state.canEdit) return null - const [sync, ...menuActions] = state.actions return (
-
- {sync && } - - - - - - {menuActions.map((action) => ( - - {action.text} - - ))} - - +
+
@@ -62,7 +30,7 @@ interface ConnectorActionFeedbackProps { export function ConnectorActionFeedback({ state }: ConnectorActionFeedbackProps) { const deleteDocumentsId = useId() if (!state.canEdit) return null - const { removal } = state + const { removal, fullResync } = state return ( <> {state.error && ( @@ -70,14 +38,30 @@ export function ConnectorActionFeedback({ state }: ConnectorActionFeedbackProps) {state.error.message} )} + + {fullResync.error?.message} + - {!removal.syncsPerMember && ( + {!removal.requiresDocumentDeletion && (
({ ChipConfirmModal: ({ open, title, + text, children, confirm, onOpenChange, }: { open: boolean title: string + text?: string children: ReactNode confirm: { label: string; onClick: () => void; pending?: boolean; disabled?: boolean } onOpenChange: (open: boolean) => void }) => open ? (
+ {text} {children} + ), + } +}) + +describe('SearchSourceStatus navigation', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + root = createRoot(container) + push.mockClear() + host.mockReturnValue(null) + }) + + afterEach(() => act(() => root.unmount())) + + function render(scope: ResourceScope) { + act(() => + root.render( + + ) + ) + } + + it('opens the owning organization search route', () => { + render({ kind: 'organization', organizationId: 'org-1' }) + act(() => container.querySelector('button')?.click()) + expect(push).toHaveBeenCalledWith('/o/org-1/search') + }) + + it.each([ + ['workspace-1', true, true, true, true], + ['workspace-1', false, true, true, false], + ['workspace-1', true, false, true, false], + ['workspace-1', true, true, false, false], + ['another-workspace', true, true, true, false], + ])( + 'gates workspace entry using routed host access: %s %s %s %s', + (id, isMember, organizationSearch, knowledgeMemberAccess, visible) => { + host.mockReturnValue({ + workspace: { id }, + hostOrganizationId: 'org-1', + viewer: { isHostOrganizationMember: isMember }, + features: { organizationSearch, knowledgeMemberAccess }, + }) + render({ kind: 'workspace', workspaceId: 'workspace-1' }) + const button = container.querySelector('button') + expect(Boolean(button)).toBe(visible) + if (visible) { + act(() => button?.click()) + expect(push).toHaveBeenCalledWith('/o/org-1/search') + } + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx index 778f7227fd0..b9f2ecb2c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx @@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation' import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' import { ConnectorsSection } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import type { ConnectorData } from '@/hooks/queries/kb/connectors' @@ -33,6 +34,16 @@ export function SearchSourceStatus({ onClose, }: SearchSourceStatusProps) { const router = useRouter() + const host = useOptionalWorkspaceHostContext() + const organizationId = + scope.kind === 'organization' + ? scope.organizationId + : host?.workspace.id === scope.workspaceId && + host.viewer.isHostOrganizationMember && + host.features?.organizationSearch && + host.features.knowledgeMemberAccess + ? host.hostOrganizationId + : null const title = `${CONNECTOR_META_REGISTRY[connectorType]?.name ?? 'Source'} sources` return ( - - router.push( - scope.kind === 'organization' - ? organizationRoutes(scope.organizationId).search - : `/workspace/${scope.workspaceId}/home?mode=search` - ), - }} - /> + {organizationId && ( + router.push(organizationRoutes(organizationId).search), + }} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx index 4e6cc481a21..ae6034788b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx @@ -10,8 +10,8 @@ const { primaryActions } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ - ButtonGroup: ({ children }: { children: ReactNode }) =>
{children}
, - ButtonGroupItem: ({ children }: { children: ReactNode }) =>
{children}
, + ChipButtonGroup: ({ children }: { children: ReactNode }) =>
{children}
, + ChipButtonGroupItem: ({ children }: { children: ReactNode }) =>
{children}
, ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx index 087e20adc11..9fe602c6269 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx @@ -2,8 +2,8 @@ import { useState } from 'react' import { - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipModal, ChipModalBody, ChipModalError, @@ -143,18 +143,18 @@ export function CreateApiKeyModal({ {canManageWorkspaceKeys && ( - { setKeyType(value as 'personal' | 'workspace') if (createError) setCreateError(null) }} > - + Personal - - Workspace - + + Workspace + )}
- setFormData({ ...formData, isPublic: value === 'public' })} > - API Key - Public - + API Key + Public + {formData.isPublic && ( No authentication required diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 8450d813d4b..38e12407418 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge, Button, - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipConfirmModal, ChipInput, ChipModal, @@ -505,17 +505,17 @@ function ServerDetailView({ MCP Client
- setActiveConfigTab(v as McpClientType)} > - Cursor - Codex - Claude Code - Claude Desktop - VS Code - Sim - + Cursor + Codex + Claude Code + Claude Desktop + VS Code + Sim +
{activeConfigTab === 'sim' ? ( @@ -850,13 +850,13 @@ function ServerDetailView({ />
- setEditServerIsPublic(value === 'public')} > - API Key - Public - + API Key + Public +

{editServerIsPublic ? 'Anyone with the URL can call this server without authentication' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index 7d545395d2a..3d6a8e8724d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -3,8 +3,8 @@ import { useMemo, useRef, useState } from 'react' import { Button, - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipCombobox, ChipModal, ChipModalBody, @@ -395,10 +395,12 @@ export function ImportCsvDialog({ - - Append - {canReplace && Replace all rows} - + + Append + {canReplace && ( + Replace all rows + )} + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index 53ac2d9fe8d..90fd0769377 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -3,8 +3,8 @@ import { useMemo, useState } from 'react' import { Button, - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, Code, Combobox, Label, @@ -460,13 +460,13 @@ console.log(limits);`

- setLanguage(val as CodeLanguage)}> + setLanguage(val as CodeLanguage)}> {(Object.keys(LANGUAGE_LABELS) as CodeLanguage[]).map((lang) => ( - + {LANGUAGE_LABELS[lang]} - + ))} - +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 0ca5b49384f..166106fb72c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react' import { - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipConfirmModal, ChipEmailsInput, ChipInput, @@ -733,17 +733,17 @@ function AuthSelector({ - onAuthTypeChange(val as AuthType)} disabled={disabled} > {authOptions.map((type) => ( - + {AUTH_LABELS[type]} - + ))} - +
{authType === 'password' && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index 70304e621ab..66b018a4def 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -3,8 +3,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Badge, - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipConfirmModal, ChipModal, ChipModalBody, @@ -218,13 +218,13 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro {!isPublicApiDisabled && ( - setAccessMode(val as 'api_key' | 'public')} > - API Key - Public - + API Key + Public +

{accessMode === 'public' ? 'Anyone can call this API without authentication. You will be billed for all usage.' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index e93b65b4a60..d567007488d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -3,8 +3,8 @@ import { useId, useState } from 'react' import { Button, - ButtonGroup, - ButtonGroupItem, + ChipButtonGroup, + ChipButtonGroupItem, ChipConfirmModal, ChipModal, ChipModalBody, @@ -204,19 +204,19 @@ export function GeneralDeploy({ : 'Live Workflow'}

- setShowActiveDespiteSelection((val as PreviewMode) === 'active') } > - Live - + Live + {selectedVersionInfo ? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name) : `v${selectedVersion}`} - - + +
diff --git a/apps/sim/background/knowledge-connector-sync.test.ts b/apps/sim/background/knowledge-connector-sync.test.ts index 3a34db9c311..4dd9c91e776 100644 --- a/apps/sim/background/knowledge-connector-sync.test.ts +++ b/apps/sim/background/knowledge-connector-sync.test.ts @@ -182,6 +182,20 @@ describe('knowledge connector sync worker', () => { ).toBe('completed') }) + it('keeps a held deletion pass a completed task', () => { + const clean = { + docsAdded: 0, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 90, + docsSkipped: 0, + docsFailed: 0, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, + } + expect(classifyConnectorSyncResult({ ...clean, listingIncomplete: false })).toBe('completed') + expect(classifyConnectorSyncResult({ ...clean, listingIncomplete: true })).toBe('partial') + }) + it('classifies an isolated processing dispatch failure as partial', () => { expect( classifyConnectorSyncResult({ diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index e74c7f70f15..2f58bceacf7 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -49,7 +49,7 @@ export const MothershipBlock: BlockConfig = { id: 'conversationId', title: 'Conversation ID', type: 'short-input', - placeholder: 'e.g., user-123, session-abc, customer-456', + placeholder: 'e.g., customer-456 (reuse the same value to continue a thread)', }, { id: 'attachmentFiles', @@ -117,7 +117,8 @@ export const MothershipBlock: BlockConfig = { }, conversationId: { type: 'string', - description: 'Chat ID to continue; generated when omitted', + description: + 'Stable id of the thread to continue; the same value in this workspace continues the same conversation. Generated when omitted', }, files: { type: 'file', @@ -131,7 +132,10 @@ export const MothershipBlock: BlockConfig = { outputs: { content: { type: 'string', description: 'Generated response content' }, model: { type: 'string', description: 'Model used for generation' }, - conversationId: { type: 'string', description: 'Chat ID used for this request' }, + conversationId: { + type: 'string', + description: 'Conversation id for this thread; pass it to another Sim block to continue it', + }, tokens: { type: 'json', description: 'Token usage statistics' }, toolCalls: { type: 'json', description: 'Tool calls made during execution' }, cost: { type: 'json', description: 'Cost of the execution' }, diff --git a/apps/sim/components/charts/bar-chart.tsx b/apps/sim/components/charts/bar-chart.tsx index 17acfbe4cb9..2e04c6e12e7 100644 --- a/apps/sim/components/charts/bar-chart.tsx +++ b/apps/sim/components/charts/bar-chart.tsx @@ -45,6 +45,8 @@ interface BarChartProps { /** `''` | `'%'` | `'ms'` | `'latency'` | `'credits'` — drives tick and tooltip formatting. */ unit?: string height?: number + /** Display bucket dates in this zone; omitted uses the viewer’s local zone. */ + timeZone?: string /** Bucket drawn at full opacity, e.g. the period in progress. */ highlightIndex?: number } @@ -75,6 +77,7 @@ function BarChartComponent({ unit, height = CHART_DEFAULT_HEIGHT, highlightIndex, + timeZone, }: BarChartProps) { /* `useId`, not `useRef(generateShortId())`: a ref initializer is evaluated on @@ -311,7 +314,7 @@ function BarChartComponent({ textAnchor='middle' fill={CHART_TICK_FILL} > - {Number.isNaN(date.getTime()) ? '' : formatTimeTick(date, spanMs)} + {Number.isNaN(date.getTime()) ? '' : formatTimeTick(date, spanMs, timeZone)} ) })} @@ -352,7 +355,7 @@ function BarChartComponent({ (() => { const bar = bars[hoverIndex] const value = formatBarValue(bar.point.value, unit) - const date = formatChartTimestamp(bar.point.timestamp) + const date = formatChartTimestamp(bar.point.timestamp, timeZone) const { left, top } = positionChartTooltip({ anchorX: hoverPos?.x ?? bar.x, anchorY: hoverPos?.y ?? bar.y, diff --git a/apps/sim/components/charts/chart-format.ts b/apps/sim/components/charts/chart-format.ts index c5b7151814b..fe31db771d2 100644 --- a/apps/sim/components/charts/chart-format.ts +++ b/apps/sim/components/charts/chart-format.ts @@ -16,10 +16,15 @@ export function formatChartLatency(ms: number): string { } /** The tooltip's header line: `MAR 4 3:05 PM`. Empty for an unparseable timestamp. */ -export function formatChartTimestamp(timestamp?: string): string { +export function formatChartTimestamp(timestamp?: string, timeZone?: string): string { if (!timestamp) return '' const date = new Date(timestamp) if (Number.isNaN(date.getTime())) return '' + if (timeZone) { + const day = date.toLocaleDateString('en-US', { timeZone, month: 'short', day: 'numeric' }) + const time = date.toLocaleTimeString('en-US', { timeZone, hour: 'numeric', minute: '2-digit' }) + return `${day.toUpperCase()} ${time}` + } return `${format(date, 'MMM d').toUpperCase()} ${format(date, 'h:mm a')}` } diff --git a/apps/sim/components/charts/chart-geometry.test.ts b/apps/sim/components/charts/chart-geometry.test.ts index 6108dc3386b..735d7f87ac0 100644 --- a/apps/sim/components/charts/chart-geometry.test.ts +++ b/apps/sim/components/charts/chart-geometry.test.ts @@ -118,3 +118,9 @@ describe('resolveChartPadding', () => { expect(padding.bottom).toBe(CHART_PADDING.bottom) }) }) + +it('keeps UTC bucket labels independent of the viewer time zone', () => { + const bucket = new Date('2026-09-10T00:00:00.000Z') + expect(formatTimeTick(bucket, 7 * 24 * 60 * 60 * 1000, 'UTC')).toBe('Sep 10') + expect(formatTimeTick(bucket, 7 * 24 * 60 * 60 * 1000, 'America/Los_Angeles')).toBe('Sep 9') +}) diff --git a/apps/sim/components/charts/chart-geometry.ts b/apps/sim/components/charts/chart-geometry.ts index 3494543c3c2..9c05f7f36c9 100644 --- a/apps/sim/components/charts/chart-geometry.ts +++ b/apps/sim/components/charts/chart-geometry.ts @@ -110,14 +110,19 @@ export function resolveTimeTickIndices(pointCount: number, usableWidth: number): * Tick label whose precision follows the window: clock time within a day and a half, * calendar day within a quarter, month beyond that. */ -export function formatTimeTick(date: Date, spanMs: number): string { +export function formatTimeTick(date: Date, spanMs: number, timeZone?: string): string { if (spanMs <= 36 * 60 * 60 * 1000) { - return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + return date.toLocaleTimeString('en-US', { + timeZone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) } if (spanMs <= 90 * 24 * 60 * 60 * 1000) { - return date.toLocaleString('en-US', { month: 'short', day: 'numeric' }) + return date.toLocaleString('en-US', { timeZone, month: 'short', day: 'numeric' }) } - return date.toLocaleString('en-US', { month: 'short', year: 'numeric' }) + return date.toLocaleString('en-US', { timeZone, month: 'short', year: 'numeric' }) } /** Milliseconds between the first and last timestamp, or 0 for a degenerate series. */ diff --git a/apps/sim/components/charts/chart-layout.test.tsx b/apps/sim/components/charts/chart-layout.test.tsx index 3ab1c4e4bc8..b095d90426d 100644 --- a/apps/sim/components/charts/chart-layout.test.tsx +++ b/apps/sim/components/charts/chart-layout.test.tsx @@ -76,6 +76,27 @@ describe('BarChart rendered geometry', () => { const widths = [280, 420, 680, 1024] const peaks = [7300, 173_000, 1_234_567] + it('renders date buckets and axes for a zero-only series without a no-data overlay', () => { + const svg = mountAtWidth( + 680, + + ) + expect(container.textContent).not.toContain('No data') + expect(container.textContent).toContain('Jan 1') + expect(container.textContent).toContain('Jan 7') + expect(container.textContent).not.toContain('Dec 31') + expect(svg.querySelectorAll('text').length).toBeGreaterThan(2) + expect(svg.querySelectorAll('rect')).toHaveLength(7) + expect(svg.querySelectorAll('rect[fill^="url"]')).toHaveLength(0) + expect(svg.querySelectorAll('line').length).toBeGreaterThan(0) + }) + it.each(widths.flatMap((width) => peaks.map((peak) => [width, peak] as const)))( 'keeps the y-axis labels inside the box at width %i, peak %i', (width, peak) => { diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index 9473b345fab..3f33eee1fc3 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { - Chip, ChipLink, ChipModal, ChipModalBody, @@ -47,7 +46,6 @@ export function SlackSearchSetupWizard({ const [copyError, setCopyError] = useState(null) const error = prepare.error ?? oauth.error ?? copyError const busy = oauth.isPending - const stepNumber = step === 'manifest' ? 1 : step === 'credentials' ? 2 : 3 const configuredAppId = appId ?? prepare.data?.existingApp?.appId async function copyConfiguration() { @@ -63,6 +61,19 @@ export function SlackSearchSetupWizard({ } } + const shared = Boolean( + prepare.data?.sharedAppId && (!configuredAppId || configuredAppId === prepare.data.sharedAppId) + ) + + function installShared() { + oauth.mutate( + { organizationId, installationId, name, description, mode: 'shared' }, + { + onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl), + } + ) + } + function advance() { if (step === 'manifest') { setStep('credentials') @@ -86,6 +97,86 @@ export function SlackSearchSetupWizard({ } } + if (!prepare.data) + return ( + { + if (!open) onClose() + }} + srTitle='Sim Search in Slack' + > + + Sim Search in Slack + + + {prepare.error ? ( + {prepare.error.message} + ) : ( +

+ Loading Slack setup… +

+ )} +
+ void prepare.refetch(), + disabled: prepare.isFetching, + }, + ] + : undefined + } + /> +
+ ) + + if (shared) + return ( + { + if (!open) onClose() + }} + srTitle='Install Sim Search' + > + + Install Sim Search + + +

+ Choose your Slack workspace and approve Sim Search. +

+ {error?.message} +
+ +
+ ) + + const title = + step === 'manifest' + ? configuredAppId + ? 'Update Slack app' + : 'Create Slack app' + : step === 'credentials' + ? 'Slack app credentials' + : installationId + ? 'Reconnect in Slack' + : 'Install in Slack' + return ( { if (!open) onClose() }} - srTitle='Set up Sim Search in Slack' - size='lg' + srTitle={title} + size='md' > - {installationId ? 'Reconnect Slack Search' : 'Set up Sim Search in Slack'} + {title} -

- Step {stepNumber} of 3 ·{' '} - {step === 'manifest' - ? configuredAppId - ? 'Update your Slack app' - : 'Create your Slack app' - : step === 'credentials' - ? 'App credentials' - : 'Install in Slack'} -

- {step === 'manifest' && prepare.isPending && ( -

- Preparing your Slack app… + {step === 'manifest' && ( +

+ {configuredAppId + ? configurationCopied + ? 'Configuration copied. In Slack, replace the JSON under App Manifest and save.' + : 'Copy the configuration, then replace the JSON under App Manifest in Slack.' + : 'Create the app in Slack, then return here to add its credentials.'}

)} - {step === 'manifest' && prepare.data && ( - - {configuredAppId && !configurationCopied ? ( - void copyConfiguration()}>Copy app configuration - ) : ( - - {configuredAppId ? 'Open Slack app settings' : 'Create app in Slack'} - - )} - - )} {step === 'credentials' && ( <>

- In your Slack app, open Basic Information and copy these three - values.{' '} - {installationId ? 'Leave fields blank to keep the saved app credentials.' : ''} + Find these values under Basic Information in your Slack app.

)} {step === 'install' && ( - <> - -

- Slack will ask you to install {name}. We’ll validate the connection and enable - Search when you return. -

-

- The bot responds to direct messages and channel mentions and reads members’ email - addresses. Each member separately authorizes indexing through this same app. -

-
- - )} - {error && {error.message}} - {prepare.error && ( -
- void prepare.refetch()} disabled={prepare.isFetching}> - Retry - -
+

+ {installationId + ? 'Approve the updated permissions for' + : 'Choose your workspace and approve'}{' '} + {name} in Slack. +

)} + {error?.message}
void prepare.refetch(), + disabled: prepare.isFetching, + }, + ] + : step === 'manifest' + ? configuredAppId && !configurationCopied + ? [{ label: 'Copy configuration', onClick: () => void copyConfiguration() }] + : [ + { + custom: ( + + {configuredAppId ? 'Open app settings' : 'Create app'} + + ), + }, + ] + : undefined + } primaryAdjacentAction={ step === 'manifest' ? undefined @@ -229,12 +301,12 @@ export function SlackSearchSetupWizard({ } } primaryAction={{ - label: busy ? 'Connecting…' : step === 'install' ? 'Install in Slack' : 'Continue', + label: busy ? 'Connecting…' : step === 'install' ? title : 'Continue', onClick: advance, disabled: busy || (step === 'manifest' - ? !prepare.data + ? Boolean(configuredAppId && !configurationCopied) : !installationId && (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), }} diff --git a/apps/sim/components/permissions/permission-selector.tsx b/apps/sim/components/permissions/permission-selector.tsx index 2b568544c1c..d1ee57ba3cf 100644 --- a/apps/sim/components/permissions/permission-selector.tsx +++ b/apps/sim/components/permissions/permission-selector.tsx @@ -1,7 +1,7 @@ 'use client' import React from 'react' -import { ButtonGroup, ButtonGroupItem, cn } from '@sim/emcn' +import { ChipButtonGroup, ChipButtonGroupItem, cn } from '@sim/emcn' import type { PermissionType } from '@/lib/workspaces/permissions/utils' export type { PermissionType } @@ -16,28 +16,27 @@ interface PermissionSelectorProps { size?: SelectorSize } -const COMPACT_ITEM_CLASS = 'h-[22px] min-w-[38px] px-1.5 py-0 text-xs' - export const PermissionSelector = React.memo( ({ value, onChange, disabled = false, className, size = 'default' }) => { - const itemClass = size === 'compact' ? COMPACT_ITEM_CLASS : undefined + const itemClass = size === 'compact' ? 'min-w-[38px]' : undefined return ( - onChange(val as PermissionType)} disabled={disabled} className={cn(disabled && 'cursor-not-allowed', className)} > - + Read - - + + Write - - + + Admin - - + + ) } ) @@ -53,25 +52,24 @@ interface OrgRoleSelectorProps { size?: SelectorSize } -const COMPACT_ORG_ITEM_CLASS = 'h-[22px] min-w-[58px] px-1.5 py-0 text-xs' - export const OrgRoleSelector = React.memo( ({ value, onChange, disabled = false, className, size = 'compact' }) => { - const itemClass = size === 'compact' ? COMPACT_ORG_ITEM_CLASS : undefined + const itemClass = size === 'compact' ? 'min-w-[58px]' : undefined return ( - onChange(val as OrgRole)} disabled={disabled} className={cn(disabled && 'cursor-not-allowed', className)} > - + Member - - + + Admin - - + + ) } ) diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx new file mode 100644 index 00000000000..ae24197c9ba --- /dev/null +++ b/apps/sim/components/ui/activity-status.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import { OverflowText } from '@sim/emcn' +import { ShimmerText } from '@/components/ui/shimmer-text' + +interface ActivityStatusProps { + label: string + isActive: boolean + icon?: ReactNode +} + +/** Inline tool status with the shared shimmer while active. */ +export function ActivityStatus({ label, isActive, icon }: ActivityStatusProps) { + return ( + + {icon && ( + + )} + + {isActive ? ( + {label} + ) : undefined} + + + ) +} diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index 234f6f50a60..58003a616a4 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -1,3 +1,4 @@ +export { ActivityStatus } from '@/components/ui/activity-status' export { Button, buttonVariants } from './button' export { GeneratedPasswordInput } from './generated-password-input' export { Progress } from './progress' diff --git a/apps/sim/connectors/confluence/bulk-spaces.test.ts b/apps/sim/connectors/confluence/bulk-spaces.test.ts new file mode 100644 index 00000000000..f2e670030a6 --- /dev/null +++ b/apps/sim/connectors/confluence/bulk-spaces.test.ts @@ -0,0 +1,234 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { confluenceConnector } from '@/connectors/confluence/confluence' + +const fetchMock = vi.fn() +const config = { domain: 'example.atlassian.net', spaceKey: [] as string[] } +const keys = Array.from({ length: 120 }, (_, index) => `SPACE_${index}`) + +function requestUrl(input: string | URL | Request): URL { + return new URL(input instanceof Request ? input.url : String(input)) +} + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('Confluence bulk space validation', () => { + it('validates every selected space with bounded query sizes and follows partial pages', async () => { + const verifiedKeys: string[] = [] + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input) + const batch = url.searchParams.getAll('keys') + expect(batch.length).toBeLessThanOrEqual(50) + expect(Number(url.searchParams.get('limit'))).toBeLessThanOrEqual(50) + const page = url.searchParams.has('cursor') ? batch.slice(25) : batch.slice(0, 25) + verifiedKeys.push(...page) + return Response.json({ + results: page.map((key) => ({ key, id: key })), + _links: + batch.length > 25 && !url.searchParams.has('cursor') + ? { next: '/wiki/api/v2/spaces?cursor=second-page' } + : {}, + }) + }) + + await expect( + confluenceConnector.validateConfig( + 'token', + { ...config, spaceKey: [...keys, keys[0]] }, + { cloudId: 'cloud' } + ) + ).resolves.toEqual({ valid: true }) + expect(verifiedKeys).toEqual(keys) + expect(fetchMock).toHaveBeenCalledTimes(5) + }) + + it('bounds encoded URLs for long space keys', async () => { + const longKeys = Array.from({ length: 20 }, (_, index) => `SPACE_${index}_${'x'.repeat(240)}`) + const verifiedKeys: string[] = [] + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input) + expect(url.toString().length).toBeLessThan(2_100) + const batch = url.searchParams.getAll('keys') + verifiedKeys.push(...batch) + return Response.json({ results: batch.map((key) => ({ key, id: key })) }) + }) + await expect( + confluenceConnector.validateConfig( + 'token', + { ...config, spaceKey: longKeys }, + { cloudId: 'cloud' } + ) + ).resolves.toEqual({ valid: true }) + expect(verifiedKeys).toEqual(longKeys) + expect(fetchMock.mock.calls.length).toBeGreaterThan(1) + }) + + it('rejects an unavailable space in a later batch', async () => { + fetchMock.mockImplementation(async (input) => { + const batch = requestUrl(input).searchParams.getAll('keys') + return Response.json({ + results: batch.filter((key) => key !== 'SPACE_110').map((key) => ({ key })), + }) + }) + await expect( + confluenceConnector.validateConfig( + 'token', + { ...config, spaceKey: keys }, + { cloudId: 'cloud' } + ) + ).resolves.toEqual({ valid: false, error: 'Space not found: SPACE_110' }) + }) + + it('rejects repeated pagination instead of accepting a partial space set', async () => { + fetchMock.mockImplementation(async () => + Response.json({ + results: [], + _links: { next: '/wiki/api/v2/spaces?cursor=repeated' }, + }) + ) + await expect( + confluenceConnector.validateConfig( + 'token', + { ...config, spaceKey: keys }, + { cloudId: 'cloud' } + ) + ).resolves.toEqual({ + valid: false, + error: 'Confluence returned an incomplete space list. Try again.', + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('does not accept a later failed batch', async () => { + fetchMock.mockImplementation(async (input) => { + const batch = requestUrl(input).searchParams.getAll('keys') + return batch.includes('SPACE_50') + ? Response.json({}, { status: 403 }) + : Response.json({ results: batch.map((key) => ({ key })) }) + }) + await expect( + confluenceConnector.validateConfig( + 'token', + { ...config, spaceKey: keys }, + { cloudId: 'cloud' } + ) + ).resolves.toEqual({ valid: false, error: 'Failed to validate spaces: 403' }) + }) +}) + +describe('Confluence bulk space listing', () => { + const sourceConfig = { ...config, spaceKey: keys.slice(0, 52) } + + function page(id: string, next?: string): Response { + return Response.json({ + results: [{ id, title: id, type: 'page', status: 'current', space: { key: 'SPACE_0' } }], + _links: next ? { next: `/wiki/rest/api/content/search?cursor=${next}` } : {}, + }) + } + + it('exhausts each provider cursor before moving to the next explicit space batch', async () => { + fetchMock + .mockResolvedValueOnce(page('1', 'provider-page-2')) + .mockResolvedValueOnce(page('2')) + .mockResolvedValueOnce(page('3')) + const context: Record = { cloudId: 'cloud' } + const first = await confluenceConnector.listDocuments('token', sourceConfig, undefined, context) + const second = await confluenceConnector.listDocuments( + 'token', + sourceConfig, + first.nextCursor, + context + ) + const third = await confluenceConnector.listDocuments( + 'token', + sourceConfig, + second.nextCursor, + context + ) + expect( + [first, second, third].flatMap((result) => result.documents.map((doc) => doc.externalId)) + ).toEqual(['1', '2', '3']) + expect([first.hasMore, second.hasMore, third.hasMore]).toEqual([true, true, false]) + const requests = fetchMock.mock.calls.map(([input]) => requestUrl(input)) + expect(requests[0].searchParams.get('cql')).toBe(requests[1].searchParams.get('cql')) + expect(requests[1].searchParams.get('cursor')).toBe('provider-page-2') + expect(requests[2].searchParams.has('cursor')).toBe(false) + expect(requests[2].searchParams.get('cql')).toContain('space in ("SPACE_50","SPACE_51")') + expect(context.listingCapped).toBeUndefined() + }) + + it('marks a limit reached before later batches as incomplete for deletion reconciliation', async () => { + fetchMock.mockResolvedValueOnce(page('1')) + const context: Record = { cloudId: 'cloud' } + const result = await confluenceConnector.listDocuments( + 'token', + { ...sourceConfig, maxPages: '1' }, + undefined, + context + ) + expect(result.hasMore).toBe(false) + expect(result.nextCursor).toBeUndefined() + expect(context.listingCapped).toBe(true) + }) + + it('continues through an empty batch without widening the query', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ results: [] })).mockResolvedValueOnce(page('1')) + const context: Record = { cloudId: 'cloud' } + const first = await confluenceConnector.listDocuments('token', sourceConfig, undefined, context) + const last = await confluenceConnector.listDocuments( + 'token', + sourceConfig, + first.nextCursor, + context + ) + expect(first.documents).toEqual([]) + expect(first.hasMore).toBe(true) + expect(last.documents.map((doc) => doc.externalId)).toEqual(['1']) + expect(last.hasMore).toBe(false) + }) + + it('rejects an invalid batch continuation before requesting any content', async () => { + await expect( + confluenceConnector.listDocuments('token', sourceConfig, 'space-batches:{"batch":-1}', { + cloudId: 'cloud', + }) + ).rejects.toThrow('Invalid Confluence space continuation') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('bounds CQL requests for long space keys while retaining filters', async () => { + const longKeys = Array.from({ length: 20 }, (_, index) => `SPACE_${index}_${'x'.repeat(240)}`) + fetchMock.mockImplementation(async () => Response.json({ results: [] })) + const context: Record = { cloudId: 'cloud' } + let cursor: string | undefined + do { + const result = await confluenceConnector.listDocuments( + 'token', + { ...sourceConfig, spaceKey: longKeys, labelFilter: 'published', contentType: 'all' }, + cursor, + context + ) + cursor = result.nextCursor + } while (cursor) + const queries = fetchMock.mock.calls.map(([input]) => requestUrl(input)) + expect(queries.length).toBeGreaterThan(1) + for (const url of queries) { + expect(url.toString().length).toBeLessThan(2_300) + expect(url.searchParams.get('cql')).toContain( + 'type in ("page","blogpost") AND label="published"' + ) + } + for (const key of longKeys) { + expect( + queries.filter((url) => url.searchParams.get('cql')?.includes(`"${key}"`)) + ).toHaveLength(1) + } + }) +}) diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts index d1e5b0b7945..f4c3601b9a9 100644 --- a/apps/sim/connectors/confluence/confluence.test.ts +++ b/apps/sim/connectors/confluence/confluence.test.ts @@ -10,7 +10,9 @@ import { buildLastModifiedClause, confluenceConnector, confluenceStorageToPlainText, + DYNAMIC_CONTENT_SKIP_REASON, escapeCql, + extractConfluenceStorageText, isCurrentContent, preserveConfluenceCallouts, readIncludedLabels, @@ -770,6 +772,77 @@ describe('confluenceStorageToPlainText', () => { expect(confluenceStorageToPlainText(storage)).toBe('Public body') }) + it('keeps text authored inside legacy section and column layouts', () => { + const storage = + '' + + '50%' + + '

Linux Patching

Run the playbook.

' + + '
' + + '

Second column

' + + '
' + + expect(confluenceStorageToPlainText(storage)).toBe( + 'Linux Patching Run the playbook. Second column' + ) + }) + + it('keeps page properties tables, table-macro bodies, and status labels', () => { + const storage = + '' + + '
OwnerPlatform team
' + + '
' + + 'Name' + + '
Filtered row
' + + '
' + + '

State: Green' + + 'Approved

' + + expect(confluenceStorageToPlainText(storage)).toBe( + 'Owner Platform team Filtered row State: Approved' + ) + }) + + it('keeps new-editor panel and decision text while dropping app extensions', () => { + const storage = + '' + + 'custom' + + '

Rotate the key quarterly.

' + + '

Rotate the key quarterly.

' + + '' + + 'abc' + + 'DECIDED' + + 'Use Vault' + + '' + + 'remote' + + '

Rendered by an app

' + + expect(confluenceStorageToPlainText(storage)).toBe( + '[CALLOUT] Rotate the key quarterly. Use Vault' + ) + }) + + it('drops template placeholders and task bookkeeping but keeps task text intact', () => { + const storage = + '

Type your summary here

' + + '1u' + + 'incompleteShip it' + + '

Unbelievable

' + + expect(confluenceStorageToPlainText(storage)).toBe('Ship it Unbelievable') + }) + + it('reports whether dynamic content was removed', () => { + expect(extractConfluenceStorageText('

Local

')).toEqual({ + text: 'Local', + droppedDynamicContent: false, + }) + expect( + extractConfluenceStorageText( + '1' + ) + ).toEqual({ text: '', droppedDynamicContent: true }) + }) + it.each(['expand', 'excerpt', 'noformat'])( 'retains the authored content of the %s macro', (name) => { @@ -902,7 +975,36 @@ describe('Confluence permission-scoped content', () => { cloudId: 'cloud-1', mirrorsSourceAcls: true, }) - ).resolves.toMatchObject({ content: '', skippedExistingDisposition: 'replace' }) + ).resolves.toMatchObject({ + content: '', + skippedReason: DYNAMIC_CONTENT_SKIP_REASON, + skippedExistingDisposition: 'replace', + }) + }) + + it('names dynamic-only hub pages distinctly from genuinely empty ones', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'hub', + version: { number: 2 }, + body: { + storage: { + value: + '' + + 'project = X', + }, + }, + }) + ) + ) + const document = await confluenceConnector.getDocument('token', config, 'hub', { + cloudId: 'cloud-1', + perMemberListing: true, + memberId: 'member-1', + }) + expect(document?.skippedReason).toBe(DYNAMIC_CONTENT_SKIP_REASON) + expect(document?.skippedRetryPolicy).toBe('source-change') }) it('keeps skipped pages retryable when no usable source version is available', async () => { @@ -1015,7 +1117,7 @@ describe('Confluence permission-scoped content', () => { ) const expectedHash = 'mirrorsSourceAcls' in mode || 'perMemberListing' in mode - ? 'confluence:storage-local-body-v1:shared-page:1' + ? 'confluence:storage-local-body-v2:shared-page:1' : 'confluence:view-callouts:shared-page:1' expect(v2.documents[0].contentHash).toBe(expectedHash) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 1b4bb94e446..8a80a0d0ddf 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -39,6 +39,34 @@ import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/con const logger = createLogger('ConfluenceConnector') const PERMISSION_VALIDATION_TIMEOUT_MS = 10_000 +const SPACE_BATCH_SIZE = 50 +const SPACE_BATCH_QUERY_LENGTH = 1_800 +const SPACE_BATCH_CURSOR_PREFIX = 'space-batches:' + +/** Bounds both space lookups and CQL URLs when a source includes many spaces. */ +function spaceKeyBatches(spaceKeys: string[]): string[][] { + const batches: string[][] = [] + let batch: string[] = [] + let queryLength = 0 + for (const key of new Set(spaceKeys)) { + const encodedLength = encodeURIComponent(escapeCql(key)).length + 15 + if (encodedLength > SPACE_BATCH_QUERY_LENGTH) { + throw new Error('A Confluence space key is too long. Check the selected spaces.') + } + if ( + batch.length === SPACE_BATCH_SIZE || + queryLength + encodedLength > SPACE_BATCH_QUERY_LENGTH + ) { + batches.push(batch) + batch = [] + queryLength = 0 + } + batch.push(key) + queryLength += encodedLength + } + if (batch.length > 0) batches.push(batch) + return batches +} /** * The configured space does not exist for the caller. Confluence answers a @@ -93,6 +121,7 @@ const INLINE_FORMATTING_TAGS = new Set([ 'var', 'samp', 'time', + 'ac:inline-comment-marker', ]) /** @@ -204,33 +233,98 @@ export function preserveConfluenceCallouts(html: string): string { } const STORAGE_MACRO_SELECTOR = 'ac\\:structured-macro, ac\\:macro' +const ADF_NODE_SELECTOR = 'ac\\:adf-node' +/** Callout macros whose body is prefixed with a semantic label, as on the view path. */ +const LOCAL_CALLOUT_MACROS = new Set(['info', 'note', 'warning', 'tip', 'panel']) +/** + * Macros whose text is authored on the page itself: callouts, expand/excerpt/code + * bodies, legacy `section`/`column` layouts (which wrap the entire body of pages + * built in the old editor), Page Properties (`details`), table-wrapping macros, + * and `status` lozenges. Everything else either resolves another resource + * (include, jira, children, page tree, label reports) or is an app macro, and + * may render differently for each reader. + */ const LOCAL_STORAGE_MACROS = new Set([ - 'info', - 'note', - 'warning', - 'tip', - 'panel', + ...LOCAL_CALLOUT_MACROS, 'expand', 'excerpt', 'code', 'noformat', + 'section', + 'column', + 'details', + 'toc-zone', + 'chart', + 'table-filter', + 'table-chart', + 'table-pivot', + 'table-transformer', + 'table-excerpt', + 'table-plus', + 'status', ]) +/** New-editor nodes stored as ADF whose content is authored on the page. */ +const LOCAL_ADF_NODE_TYPES = new Set(['panel', 'decision-list', 'decision-item']) +/** ADF nodes rendered by a Forge or Connect app; their output is resolved elsewhere. */ +const APP_ADF_NODE_TYPES = new Set(['extension', 'bodiedExtension', 'inlineExtension']) +/** Storage-format bookkeeping that is never page prose. */ +const STORAGE_NOISE_SELECTOR = [ + 'ac\\:parameter', + 'ac\\:default-parameter', + 'ac\\:adf-attribute', + 'ac\\:adf-fallback', + 'ac\\:placeholder', + 'ac\\:task-id', + 'ac\\:task-uuid', + 'ac\\:task-status', + 'script', + 'style', +].join(', ') + +/** Recorded when a scoped page holds nothing but content resolved from elsewhere. */ +export const DYNAMIC_CONTENT_SKIP_REASON = + 'Page only contains dynamic content (child lists, includes, or app macros) that Search cannot index' + +export interface ConfluenceStorageText { + text: string + /** True when at least one non-local macro or app node was removed. */ + droppedDynamicContent: boolean +} /** * Search authorizes the containing page, not content expanded from another * resource. Read authored storage text and known local macro bodies only; * inclusion and third-party macros may render differently for each reader. */ -export function confluenceStorageToPlainText(storage: string): string { +export function extractConfluenceStorageText(storage: string): ConfluenceStorageText { const $ = cheerio.load( storage, { xml: { xmlMode: false, recognizeCDATA: true, recognizeSelfClosing: true } }, false ) - $('ac\\:adf-extension').remove() + let droppedDynamicContent = false + + for (const element of $(ADF_NODE_SELECTOR).toArray().reverse()) { + const node = $(element) + const type = node.attr('type') ?? '' + if (!LOCAL_ADF_NODE_TYPES.has(type)) { + if (APP_ADF_NODE_TYPES.has(type)) droppedDynamicContent = true + node.remove() + continue + } + const panelType = node.children('ac\\:adf-attribute[key="panel-type"]').text().trim() + node.children('ac\\:adf-attribute, ac\\:adf-fallback').remove() + const body = extractBlockJoinedText($, node) + const label = + type === 'panel' + ? (CALLOUT_LABELS[panelType === 'info' ? 'information' : panelType] ?? '[CALLOUT]') + : '' + node.replaceWith($('

').text([label, body].filter(Boolean).join(' '))) + } $(STORAGE_MACRO_SELECTOR).each((_, element) => { if (!LOCAL_STORAGE_MACROS.has($(element).attr('ac:name') ?? '')) { + droppedDynamicContent = true $(element).remove() } }) @@ -248,13 +342,23 @@ export function confluenceStorageToPlainText(storage: string): string { ? title ? `[CALLOUT: ${title}]` : '[CALLOUT]' - : CALLOUT_LABELS[name === 'info' ? 'information' : name] + : LOCAL_CALLOUT_MACROS.has(name) + ? CALLOUT_LABELS[name === 'info' ? 'information' : name] + : '' const text = [label, name === 'panel' ? '' : title, body].filter(Boolean).join(' ') macro.replaceWith($('

').text(text)) } - $('ac\\:parameter, ac\\:default-parameter, script, style').remove() - return extractBlockJoinedText($, $.root()).replace(/\s+/g, ' ').trim() + $(STORAGE_NOISE_SELECTOR).remove() + return { + text: extractBlockJoinedText($, $.root()).replace(/\s+/g, ' ').trim(), + droppedDynamicContent, + } +} + +/** Plain text of a storage-format body; see {@link extractConfluenceStorageText}. */ +export function confluenceStorageToPlainText(storage: string): string { + return extractConfluenceStorageText(storage).text } function usesPermissionScopedContent(syncContext?: Record): boolean { @@ -313,7 +417,7 @@ export function readIncludedLabels(page: Record): string[] { * ordinary knowledge bases retain their existing rendered representation. */ const CONTENT_REPRESENTATION = 'view-callouts' -const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v1' +const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v2' /** * Produces a canonical metadata stub with a deterministic contentHash that @@ -595,7 +699,7 @@ export const confluenceConnector: ConnectorConfig = { * `lastModified`. */ if (labelFilter.trim() || spaceKeys.length > 1 || lastSyncAt) { - return listDocumentsViaCql( + return listSpaceBatchesViaCql( cloudId, accessToken, domain, @@ -690,9 +794,8 @@ export const confluenceConnector: ConnectorConfig = { throw new Error(`Confluence content is missing its ${bodyFormat} body`) } const rawContent = representation.value - const plainText = scopedContent - ? confluenceStorageToPlainText(rawContent) - : htmlToPlainText(preserveConfluenceCallouts(rawContent)) + const scoped = scopedContent ? extractConfluenceStorageText(rawContent) : null + const plainText = scoped ? scoped.text : htmlToPlainText(preserveConfluenceCallouts(rawContent)) const links = page._links as Record | undefined const stub = pageToStub( @@ -707,7 +810,12 @@ export const confluenceConnector: ConnectorConfig = { if (!plainText.trim()) { return { - ...markSkipped(stub, 'Document contains no extractable text'), + ...markSkipped( + stub, + scoped?.droppedDynamicContent + ? DYNAMIC_CONTENT_SKIP_REASON + : 'Document contains no extractable text' + ), skippedExistingDisposition: 'replace', } } @@ -746,43 +854,64 @@ export const confluenceConnector: ConnectorConfig = { } : VALIDATE_RETRY_OPTIONS const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext, retryOptions) - const params = new URLSearchParams() - for (const key of spaceKeys) params.append('keys', key) - params.append('limit', String(Math.max(spaceKeys.length, 1))) - const spaceUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${params.toString()}` - const response = await fetchWithRetry( - spaceUrl, - { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - }, - retryOptions - ) - if (!response.ok) { - return { valid: false, error: `Failed to validate spaces: ${response.status}` } - } - const data = await response.json() - const results = (data.results as Array> | undefined) ?? [] - const foundKeys = new Set(results.map((r) => String(r.key))) - const missing = spaceKeys.filter((k) => !foundKeys.has(k)) - if (missing.length > 0) { - return { - valid: false, - error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, + let permissionSpaceId: string | undefined + for (const batch of spaceKeyBatches(spaceKeys)) { + const remainingKeys = new Set(batch) + const seenCursors = new Set() + let cursor: string | undefined + do { + const params = new URLSearchParams() + for (const key of batch) params.append('keys', key) + params.set('limit', String(batch.length)) + if (cursor) params.set('cursor', cursor) + const response = await fetchWithRetry( + `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${params}`, + { + method: 'GET', + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, + }, + retryOptions + ) + if (!response.ok) { + return { valid: false, error: `Failed to validate spaces: ${response.status}` } + } + const data = await response.json() + if (!Array.isArray(data.results)) { + throw new Error('Confluence returned an invalid space list. Try again.') + } + for (const space of data.results) { + if ( + remainingKeys.delete(space.key) && + !permissionSpaceId && + typeof space.id === 'string' + ) { + permissionSpaceId = space.id + } + } + if (remainingKeys.size === 0) break + const next = data._links?.next + cursor = extractCursor(next) + if (next && (!cursor || seenCursors.has(cursor) || seenCursors.size >= batch.length)) { + throw new Error('Confluence returned an incomplete space list. Try again.') + } + if (cursor) seenCursors.add(cursor) + } while (cursor) + if (remainingKeys.size > 0) { + const missing = [...remainingKeys] + return { + valid: false, + error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, + } } } if (syncContext?.mirrorsSourceAcls === true) { - const spaceId = results[0]?.id - if (typeof spaceId !== 'string' || !spaceId) { + if (!permissionSpaceId) { return { valid: false, error: 'Confluence returned a space without an ID. Try again.' } } await validateConfluencePermissionAccess({ cloudId, accessToken, - spaceId, + spaceId: permissionSpaceId, contentType: (sourceConfig.contentType as string) || 'page', retryOptions, }) @@ -1037,6 +1166,86 @@ export function resolveLastModifiedClause( */ const CQL_PAGE_SIZE = 50 +/** Walks one bounded space batch at a time without discarding a provider continuation. */ +async function listSpaceBatchesViaCql( + cloudId: string, + accessToken: string, + domain: string, + spaceKeys: string[], + contentType: string, + labelFilter: string, + maxPages: number, + cursor?: string, + syncContext: Record = {}, + lastSyncAt?: Date +): Promise { + const batches = spaceKeyBatches(spaceKeys) + if (batches.length === 1) { + return listDocumentsViaCql( + cloudId, + accessToken, + domain, + batches[0], + contentType, + labelFilter, + maxPages, + cursor, + syncContext, + lastSyncAt + ) + } + + let batchIndex = 0 + let providerCursor: string | undefined + if (cursor) { + const invalidCursor = new Error('Invalid Confluence space continuation. Restart the sync.') + if (!cursor.startsWith(SPACE_BATCH_CURSOR_PREFIX)) throw invalidCursor + const state: unknown = JSON.parse(cursor.slice(SPACE_BATCH_CURSOR_PREFIX.length)) + if ( + typeof state !== 'object' || + state === null || + !('batch' in state) || + typeof state.batch !== 'number' || + !Number.isSafeInteger(state.batch) || + state.batch < 0 || + state.batch >= batches.length || + ('cursor' in state && typeof state.cursor !== 'string') + ) + throw invalidCursor + batchIndex = state.batch + providerCursor = 'cursor' in state ? (state.cursor as string) : undefined + } + + const result = await listDocumentsViaCql( + cloudId, + accessToken, + domain, + batches[batchIndex], + contentType, + labelFilter, + maxPages, + providerCursor, + syncContext, + lastSyncAt + ) + const hasMoreBatches = batchIndex + 1 < batches.length + if (maxPages > 0 && Number(syncContext.totalDocsFetched) >= maxPages) { + if (hasMoreBatches) syncContext.listingCapped = true + return { ...result, hasMore: false, nextCursor: undefined } + } + if (!result.hasMore && !hasMoreBatches) return result + return { + ...result, + hasMore: true, + nextCursor: + SPACE_BATCH_CURSOR_PREFIX + + JSON.stringify({ + batch: result.hasMore ? batchIndex : batchIndex + 1, + ...(result.hasMore ? { cursor: result.nextCursor } : {}), + }), + } +} + /** * Lists documents using CQL search via the v1 API (used when label filtering is enabled). */ diff --git a/apps/sim/connectors/confluence/meta.ts b/apps/sim/connectors/confluence/meta.ts index 76076f8e608..b7b3f0782b3 100644 --- a/apps/sim/connectors/confluence/meta.ts +++ b/apps/sim/connectors/confluence/meta.ts @@ -64,7 +64,7 @@ export const confluenceConnectorMeta: ConnectorMeta = { configFields: [ { id: 'domain', - title: 'Confluence Domain', + title: 'Confluence site', type: 'short-input', placeholder: 'yoursite.atlassian.net', required: true, @@ -77,6 +77,8 @@ export const confluenceConnectorMeta: ConnectorMeta = { canonicalParamId: 'spaceKey', mode: 'basic', multi: true, + allowSelectAll: true, + preserveValueOnModeChange: true, dependsOn: ['domain'], placeholder: 'Select one or more spaces', required: true, diff --git a/apps/sim/connectors/github/github.test.ts b/apps/sim/connectors/github/github.test.ts index 53eaa6594d9..32ec8e9f9b0 100644 --- a/apps/sim/connectors/github/github.test.ts +++ b/apps/sim/connectors/github/github.test.ts @@ -7,6 +7,7 @@ import { listingFingerprint, runResumableListing, } from '@/lib/knowledge/connectors/listing-checkpoint' +import { classifyExternalDoc } from '@/lib/knowledge/connectors/sync-primitives' import { githubConnector } from '@/connectors/github/github' import type { ExternalDocument } from '@/connectors/types' import { PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils' @@ -153,6 +154,7 @@ describe('githubConnector member listing', () => { content: '', contentDeferred: true, contentHash: 'git-sha:blob-sha', + skippedRetryPolicy: 'source-change', }) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock.mock.calls[0]![1].headers.Authorization).toBe('Bearer member-token') @@ -486,6 +488,94 @@ describe('githubConnector.getDocument', () => { }) }) +describe('githubConnector content outcomes', () => { + afterEach(() => vi.unstubAllGlobals()) + + it.each([ + ['empty.txt', '', 'Empty file was not indexed'], + ['blank.txt', ' \n\t ', 'Empty file was not indexed'], + ['image.png', 'binary\0contents', 'Binary file was not indexed'], + ])( + 'records %s as a verified omission and reuses its unchanged hash', + async (path, body, reason) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(treeResponse([treeFile(path, 'blob-sha')])) + .mockResolvedValueOnce(new Response(body)) + vi.stubGlobal('fetch', fetchMock) + const context = {} + const listing = await githubConnector.listDocuments('token', source, undefined, context) + const skipped = await githubConnector.getDocument('token', source, path, context) + expect(skipped).toMatchObject({ + content: '', + contentDeferred: false, + contentHash: listing.documents[0].contentHash, + skippedReason: reason, + skippedExistingDisposition: 'replace', + }) + const existing = { id: 'document', contentHash: skipped!.contentHash, storageKey: null } + expect(classifyExternalDoc(listing.documents[0], existing)).toEqual({ type: 'unchanged' }) + expect( + classifyExternalDoc({ ...listing.documents[0], contentHash: 'git-sha:new-blob' }, existing) + ).toEqual({ type: 'update', existingId: 'document' }) + expect(classifyExternalDoc(listing.documents[0], { ...existing, contentHash: null })).toEqual( + { + type: 'update', + existingId: 'document', + } + ) + expect(fetchMock).toHaveBeenCalledTimes(2) + } + ) + + it('skips a known empty blob before downloading it', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(treeResponse([treeFile('empty.pdf', 'sha', 0)])) + vi.stubGlobal('fetch', fetchMock) + const context = {} + const listing = await githubConnector.listDocuments('token', source, undefined, context) + const document = await githubConnector.getDocument('token', source, 'empty.pdf', context) + expect(listing.documents[0]).toMatchObject({ + skippedReason: 'Empty file was not indexed', + skippedExistingDisposition: 'replace', + contentDeferred: false, + }) + expect(document?.skippedReason).toBe('Empty file was not indexed') + expect(document?.sourceFile).toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['report.pdf', 'application/pdf'], + ['report.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ])('hands the original %s bytes to the shared document processor', async (path, mimeType) => { + const bytes = Buffer.from([0x50, 0x4b, 0x00, 0x03, 0x04]) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(treeResponse([treeFile(path, 'blob-sha', bytes.length)])) + .mockResolvedValueOnce(new Response(bytes)) + vi.stubGlobal('fetch', fetchMock) + const context = {} + const listing = await githubConnector.listDocuments('token', source, undefined, context) + const document = await githubConnector.getDocument('token', source, path, context) + expect(document).toMatchObject({ + content: '', + contentDeferred: false, + mimeType, + sourceFile: { bytes, fileName: path, mimeType }, + contentHash: listing.documents[0].contentHash, + }) + expect(document?.skippedReason).toBeUndefined() + expect( + classifyExternalDoc(listing.documents[0], { + id: 'previously-skipped', + contentHash: 'git-sha:blob-sha', + storageKey: null, + }) + ).toEqual({ type: 'update', existingId: 'previously-skipped' }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) + describe('githubConnector symlinks', () => { afterEach(() => vi.unstubAllGlobals()) @@ -574,6 +664,43 @@ describe('githubConnector symlinks', () => { ]) }) + it('skips a directory link without requesting a directory as file content', async () => { + const directory = { ...treeFile('docs/folder'), mode: '040000', type: 'tree' } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(treeResponse([link, directory])) + .mockResolvedValueOnce(new Response('folder')) + vi.stubGlobal('fetch', fetchMock) + await expect(githubConnector.getDocument('token', source, link.path)).resolves.toMatchObject({ + skippedReason: 'Symbolic link target is not a repository file', + skippedRetryPolicy: 'source-change', + skippedExistingDisposition: 'replace', + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('routes a document symlink through the shared parser using its displayed file format', async () => { + const documentLink = { ...link, path: 'docs/report.pdf' } + const bytes = Buffer.from('%PDF-1.7\n\0binary document content') + const fetchMock = vi + .fn() + .mockResolvedValueOnce(treeResponse([documentLink, target])) + .mockResolvedValueOnce(new Response('target.md')) + .mockResolvedValueOnce(new Response(bytes)) + vi.stubGlobal('fetch', fetchMock) + const context = {} + const listing = await githubConnector.listDocuments('token', source, undefined, context) + const document = await githubConnector.getDocument('token', source, documentLink.path, context) + expect(document).toMatchObject({ + externalId: documentLink.path, + title: 'report.pdf', + sourceFile: { bytes, fileName: 'report.pdf', mimeType: 'application/pdf' }, + contentHash: listing.documents[0].contentHash, + }) + expect(document?.skippedReason).toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + it('follows an in-repository link chain outside the configured listing prefix', async () => { const nextLink = { ...treeFile('intermediate.md', 'next-link-sha'), mode: '120000' } vi.stubGlobal( @@ -657,6 +784,7 @@ describe('githubConnector symlinks', () => { await expect(githubConnector.getDocument('token', source, link.path)).resolves.toMatchObject({ content: '', skippedReason: reason, + skippedExistingDisposition: 'replace', }) } }) diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts index d1f71f951ae..f19991f8486 100644 --- a/apps/sim/connectors/github/github.ts +++ b/apps/sim/connectors/github/github.ts @@ -15,6 +15,7 @@ import { isPerMemberListing, markSkipped, parseTagDate, + pipelineParsedMimeType, readBodyWithLimit, sizeLimitSkipReason, stubOrSkipBySize, @@ -34,6 +35,8 @@ const GITHUB_API_URL = 'https://api.github.com' const BATCH_SIZE = 200 const GIT_SHA_PREFIX = 'git-sha:' const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES +/** Rehydrates formats formerly skipped or decoded as plain text once through their real parser. */ +const SOURCE_FILE_HASH_SUFFIX = ':source-file-v1' const BINARY_SNIFF_BYTES = 8000 const MAX_SYMLINK_DEPTH = 40 const MAX_SYMLINK_TARGET_BYTES = 4096 @@ -43,6 +46,7 @@ const MAX_SYMLINK_TARGET_BYTES = 4096 * re-downloaded in full on every sync. */ const BINARY_SKIP_REASON = 'Binary file was not indexed' +const EMPTY_SKIP_REASON = 'Empty file was not indexed' /** * Heuristic binary detection: Git treats files containing a NUL byte in the @@ -260,14 +264,14 @@ async function fetchTree( return snapshot } -/** Streams a Git blob with the same binary and byte bounds used for ordinary files. */ -async function fetchBlobContent( +/** Keeps original bytes available to the shared parsers while bounding every blob read. */ +async function fetchBlobBytes( accessToken: string, owner: string, repo: string, sha: string, maxBytes = MAX_FILE_SIZE -): Promise { +): Promise { const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs/${encodeURIComponent(sha)}` const label = `git blob ${sha}` const response = await fetchWithRetry(url, { @@ -296,8 +300,7 @@ async function fetchBlobContent( if (!buffer) { throw new ConnectorFileTooLargeError(maxBytes) } - if (isBinaryBuffer(buffer)) return null - return decodeTextBuffer(buffer).text + return buffer } /** Resolves links within one snapshot; Contents can truncate dereferenced targets at 1 MiB. */ @@ -314,13 +317,21 @@ async function resolveSymlinkTarget( if (item.mode !== '120000') return item.type === 'blob' ? item : null if (visited.has(item.path) || (item.size ?? 0) > MAX_SYMLINK_TARGET_BYTES) return null visited.add(item.path) - let target: string | null + let targetBytes: Buffer try { - target = await fetchBlobContent(accessToken, owner, repo, item.sha, MAX_SYMLINK_TARGET_BYTES) + targetBytes = await fetchBlobBytes( + accessToken, + owner, + repo, + item.sha, + MAX_SYMLINK_TARGET_BYTES + ) } catch (error) { if (error instanceof ConnectorFileTooLargeError) return null throw error } + if (isBinaryBuffer(targetBytes)) return null + const target = decodeTextBuffer(targetBytes).text if (!target || posix.isAbsolute(target)) return null const targetPath = posix.normalize(posix.join(posix.dirname(item.path), target)) if (targetPath === '..' || targetPath.startsWith('../')) return null @@ -356,10 +367,13 @@ function treeItemToStub( title: item.path.split('/').pop() || item.path, content: '', contentDeferred: true, + skippedRetryPolicy: 'source-change', + /** Verified immutable omissions replace older content; fetch failures still throw. */ + skippedExistingDisposition: 'replace', mimeType: 'text/plain', sourceUrl: `https://github.com/${owner}/${repo}/blob/${branch.split('/').map(encodeURIComponent).join('/')}/${item.path.split('/').map(encodeURIComponent).join('/')}`, /** Contents dereferences symlinks but retains their SHA even when the target changes. */ - contentHash: `${GIT_SHA_PREFIX}${item.sha}${item.mode === '120000' ? `:${treeSha}` : ''}`, + contentHash: `${GIT_SHA_PREFIX}${item.sha}${item.mode === '120000' ? `:${treeSha}` : ''}${pipelineParsedMimeType(item.path) ? SOURCE_FILE_HASH_SUFFIX : ''}`, metadata: { path: item.path, sha: item.sha, @@ -464,13 +478,14 @@ export const githubConnector: ConnectorConfig = { batchSize: batch.length, }) - const documents = batch.map((item) => - stubOrSkipBySize( + const documents = batch.map((item) => { + const stub = stubOrSkipBySize( treeItemToStub(owner, repo, branch, item, snapshot.sha), item.size, MAX_FILE_SIZE ) - ) + return item.size === 0 ? markSkipped(stub, EMPTY_SKIP_REASON) : stub + }) const nextOffset = offset + BATCH_SIZE const hasMore = nextOffset < capped.length @@ -509,22 +524,32 @@ export const githubConnector: ConnectorConfig = { const size = target?.size ?? 0 const stub = treeItemToStub(owner, repo, branch, { ...treeItem, size }, snapshot.sha) if (!target) { - return { - ...markSkipped(stub, 'Symbolic link target is not a repository file'), - skippedExistingDisposition: 'replace', - } + return markSkipped(stub, 'Symbolic link target is not a repository file') } if (size > MAX_FILE_SIZE) return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE)) + if (target.size === 0) return markSkipped(stub, EMPTY_SKIP_REASON) /** The immutable listed blob avoids ref drift and a redundant Contents API request. */ - let content: string | null + let bytes: Buffer try { - content = await fetchBlobContent(accessToken, owner, repo, target.sha) + bytes = await fetchBlobBytes(accessToken, owner, repo, target.sha) } catch (error) { if (error instanceof ConnectorFileTooLargeError) return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE)) throw error } - if (content === null) return markSkipped(stub, BINARY_SKIP_REASON) + if (bytes.length === 0) return markSkipped(stub, EMPTY_SKIP_REASON) + const mimeType = pipelineParsedMimeType(stub.title) + if (mimeType) { + return { + ...stub, + contentDeferred: false, + mimeType, + sourceFile: { bytes, fileName: stub.title, mimeType }, + } + } + if (isBinaryBuffer(bytes)) return markSkipped(stub, BINARY_SKIP_REASON) + const content = decodeTextBuffer(bytes).text + if (!content.trim()) return markSkipped(stub, EMPTY_SKIP_REASON) return { ...stub, diff --git a/apps/sim/connectors/github/installation-connection-field.tsx b/apps/sim/connectors/github/installation-connection-field.tsx new file mode 100644 index 00000000000..f5f23f9324f --- /dev/null +++ b/apps/sim/connectors/github/installation-connection-field.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react' +import { Chip, ChipCombobox, ChipModalError, ChipModalField } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import type { Credential } from '@/lib/oauth' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' + +interface GitHubInstallationConnectionFieldProps { + installations: Credential[] + credentialId: string | null + isLoading: boolean + isFetching: boolean + error: Error | null + disabled: boolean + onRetry: () => void + onConnect: () => void + onChange: (credentialId: string) => void + connecting?: boolean + connectionError?: string | null + hint?: string + onCancel?: () => void + children?: ReactNode +} + +export function GitHubInstallationConnectionField({ + installations, + credentialId, + isLoading, + isFetching, + error, + disabled, + onRetry, + onConnect, + onChange, + connecting = false, + connectionError, + hint, + onCancel, + children, +}: GitHubInstallationConnectionFieldProps) { + return ( + + {connecting ? ( +
+ Connecting GitHub… + + Cancel + +
+ ) : error && installations.length === 0 ? ( + + ) : installations.length > 0 ? ( + ({ + value: credential.id, + label: credential.name || 'GitHub', + })), + { + value: '__connect_github__', + label: 'Connect another organization', + icon: Plus, + onSelect: onConnect, + }, + ]} + value={credentialId ?? undefined} + onChange={onChange} + placeholder='Select GitHub connection' + disabled={disabled} + /> + ) : ( + + {isLoading ? 'Loading GitHub…' : 'Connect GitHub'} + + )} + {connectionError} + {children} +
+ ) +} diff --git a/apps/sim/connectors/gitlab/gitlab.test.ts b/apps/sim/connectors/gitlab/gitlab.test.ts index a9076957454..ef81e0057b3 100644 --- a/apps/sim/connectors/gitlab/gitlab.test.ts +++ b/apps/sim/connectors/gitlab/gitlab.test.ts @@ -14,6 +14,7 @@ vi.mock('@/connectors/gitlab/permissions', () => ({ import { gitlabConnector } from '@/connectors/gitlab/gitlab' import { gitlabConnectorMeta } from '@/connectors/gitlab/meta' +import { setGitLabCsvContext } from '@/connectors/gitlab/permission-config/types' import type { ExternalDocument } from '@/connectors/types' import { CONNECTOR_TEXT_DOCUMENT_MAX_BYTES } from '@/connectors/utils' @@ -366,4 +367,88 @@ describe('GitLab connector provider lifecycle', () => { 'merge requests: 403' ) }) + + it('excludes confidential and unknown-status issues before hydration in CSV mode', async () => { + const context = { mirrorsSourceAcls: true } + setGitLabCsvContext(context, { + connectorId: 'csv-connector', + host: 'gitlab.example.com:8443', + projectId: 42, + projectPath: 'group/project', + }) + const listing = await gitlabConnector.listDocuments( + 'pat', + { ...config, contentTypes: 'issues' }, + undefined, + context + ) + expect(listing.documents[0]).toMatchObject({ + title: 'Excluded GitLab issue', + content: '', + acl: [], + skippedExistingDisposition: 'replace', + }) + expect(JSON.stringify(listing.documents[0])).not.toContain('Investigate indexing') + const hydrated = await gitlabConnector.getDocument('pat', config, 'issue:1', context) + expect(hydrated).toMatchObject({ skippedExistingDisposition: 'replace', acl: [], content: '' }) + expect(calls.some((call) => call.url.pathname.endsWith('/notes'))).toBe(false) + }) + + it('revokes an issue that becomes confidential between listing and hydration', async () => { + const context = { mirrorsSourceAcls: true } + setGitLabCsvContext(context, { + connectorId: 'csv-connector', + host: 'gitlab.example.com:8443', + projectId: 42, + projectPath: 'group/project', + }) + issues[0].confidential = false + const listing = await gitlabConnector.listDocuments( + 'pat', + { ...config, contentTypes: 'issues' }, + undefined, + context + ) + expect(listing.documents[0].skippedReason).toBeUndefined() + issues[0].confidential = true + expect(await gitlabConnector.getDocument('pat', config, 'issue:1', context)).toMatchObject({ + skippedExistingDisposition: 'replace', + content: '', + acl: [], + }) + expect(calls.some((call) => call.url.pathname.endsWith('/notes'))).toBe(false) + }) + + it('excludes a newly hidden issue only after confirming the project is still readable', async () => { + const context = { mirrorsSourceAcls: true, projectPath: 'group/project' } + setGitLabCsvContext(context, { + connectorId: 'csv-connector', + host: 'gitlab.example.com:8443', + projectId: 42, + projectPath: 'group/project', + }) + override = ({ url }) => + url.pathname.endsWith('/issues/1') ? new Response(null, { status: 404 }) : undefined + expect(await gitlabConnector.getDocument('pat', config, 'issue:1', context)).toMatchObject({ + skippedExistingDisposition: 'replace', + content: '', + acl: [], + }) + expect(calls.some(({ url }) => url.pathname === PROJECT_PATH)).toBe(true) + override = () => new Response(null, { status: 404 }) + await expect(gitlabConnector.getDocument('pat', config, 'issue:1', context)).rejects.toThrow( + 'Cannot access GitLab project' + ) + }) + + it('does not trust CSV setup supplied through sourceConfig', async () => { + const doc = await gitlabConnector.getDocument( + 'pat', + { ...config, permissionConfig: { mode: 'csv' } }, + 'issue:1', + { mirrorsSourceAcls: true } + ) + expect(doc?.skippedReason).toBeUndefined() + expect(doc?.metadata?.confidential).toBe(true) + }) }) diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index 95c794b2a36..6b791d8de97 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -7,6 +7,11 @@ import { decodeTextBuffer } from '@/lib/file-parsers/utils' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { gitlabConnectorMeta } from '@/connectors/gitlab/meta' +import { gitLabPermissionConfig } from '@/connectors/gitlab/permission-config/capability' +import { + getGitLabCsvContext, + setGitLabCsvContext, +} from '@/connectors/gitlab/permission-config/types' import { getGitLabDocumentAcls, openGitLabDirectory, @@ -611,6 +616,9 @@ function workItemToStub( kind: WorkItemKind, syncContext?: Record ): ExternalDocument { + if (kind === 'issue' && getGitLabCsvContext(syncContext) && item.confidential !== false) { + return excludedCsvIssue(item.iid) + } const title = item.title?.trim() || `${kind === 'issue' ? 'Issue #' : 'Merge Request !'}${item.iid}` const resource = kind === 'issue' ? 'issues' : 'merge_requests' @@ -643,6 +651,25 @@ function workItemToStub( } } +/** An authoritative exclusion revokes any older indexed version without retaining private metadata. */ +function excludedCsvIssue(iid: number): ExternalDocument { + return { + ...markSkipped( + { + externalId: `${ISSUE_PREFIX}${iid}`, + title: 'Excluded GitLab issue', + content: '', + contentHash: `gitlab:excluded-issue:${iid}`, + mimeType: 'text/plain', + metadata: { contentType: 'issue' }, + acl: [], + }, + 'Non-admin token connections exclude issues unless GitLab explicitly marks them non-confidential.' + ), + skippedExistingDisposition: 'replace', + } +} + /** Notes share their parent audience only when neither internal nor confidential. */ async function hydrateWorkItem( accessToken: string, @@ -764,6 +791,10 @@ async function resolveProjectPath( } const project = (await response.json()) as GitLabProject + const csv = getGitLabCsvContext(syncContext) + if (csv && (project.id !== csv.projectId || new URL(apiBase).host !== csv.host)) { + throw new Error('GitLab project identity changed. Reconfigure the connection before syncing.') + } const path = project.path_with_namespace ?? '' if (syncContext) { if (path) syncContext.projectPath = path @@ -886,6 +917,7 @@ function applyMaxItemsCap( export const gitlabConnector: ConnectorConfig = { ...gitlabConnectorMeta, + permissionConfig: gitLabPermissionConfig, openDirectory: openGitLabDirectory, getDocumentAcls: getGitLabDocumentAcls, @@ -1230,11 +1262,24 @@ export const gitlabConnector: ConnectorConfig = { maxResponseBytes: MAX_METADATA_RESPONSE_BYTES, }) if (!response.ok) { - if (response.status === 404) return null + if (response.status === 404) { + const csv = getGitLabCsvContext(syncContext) + if (kind === 'issue' && csv) { + /** A fresh project check distinguishes item exclusion from lost project access. */ + const currentContext = {} + setGitLabCsvContext(currentContext, csv) + await resolveProjectPath(currentContext, apiBase, encodedProject, accessToken) + return excludedCsvIssue(iid) + } + return null + } throw new Error(`Failed to fetch GitLab ${kind}: ${response.status}`) } const item = readWorkItem(await response.json()) if (item.iid !== iid) throw new Error('GitLab returned a different issue or merge request') + if (kind === 'issue' && getGitLabCsvContext(syncContext) && item.confidential !== false) { + return excludedCsvIssue(iid) + } return hydrateWorkItem(accessToken, apiBase, encodedProject, host, projectPath, item, kind) } @@ -1313,7 +1358,7 @@ export const gitlabConnector: ConnectorConfig = { const choice = getContentTypeChoice(sourceConfig) try { - if (syncContext?.mirrorsSourceAcls === true) { + if (syncContext?.mirrorsSourceAcls === true && !getGitLabCsvContext(syncContext)) { await validateGitLabPermissionToken(accessToken, sourceConfig) } const response = await fetchProject( @@ -1334,6 +1379,13 @@ export const gitlabConnector: ConnectorConfig = { } const projectRecord = (await response.json()) as GitLabProject + const csv = getGitLabCsvContext(syncContext) + if (csv && (projectRecord.id !== csv.projectId || host !== csv.host)) { + return { + valid: false, + error: 'GitLab returned a different project. Reload the connection settings.', + } + } if (activePhases(choice).includes('wiki')) { const accessLevel = projectRecord.wiki_access_level diff --git a/apps/sim/connectors/gitlab/meta.ts b/apps/sim/connectors/gitlab/meta.ts index 9734c76d449..586089d9735 100644 --- a/apps/sim/connectors/gitlab/meta.ts +++ b/apps/sim/connectors/gitlab/meta.ts @@ -10,8 +10,9 @@ export const gitlabConnectorMeta: ConnectorMeta = { 'Sync repository files, wiki pages, issues, merge requests, and their non-internal comments from a GitLab project', version: '1.3.0', mirrorsSourceAcls: true, + supportedAccessModes: ['admin'], adminSetupHint: - 'Use a self-managed GitLab instance administrator token with read_api access (and admin_mode when required). Enter your instance host.', + 'Use an administrator token, or a non-admin token with CSV permissions. Both require read_api access and a self-managed GitLab host.', icon: GitLabIcon, /** diff --git a/apps/sim/connectors/gitlab/permission-config/capability.ts b/apps/sim/connectors/gitlab/permission-config/capability.ts new file mode 100644 index 00000000000..8e469ea538e --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/capability.ts @@ -0,0 +1,48 @@ +import type { ConnectorPermissionConfigCapability } from '@/lib/knowledge/connectors/permission-config' +import { prepareGitLabPermissionChange } from '@/connectors/gitlab/permission-config/prepare' +import { + readGitLabPermissionSummaries, + seedGitLabCsvContext, + writeGitLabPermissions, +} from '@/connectors/gitlab/permission-config/repository' +import { setGitLabCsvContext } from '@/connectors/gitlab/permission-config/types' + +export const gitLabPermissionConfig: ConnectorPermissionConfigCapability = { + async prepare(input) { + const change = await prepareGitLabPermissionChange({ + ...input, + connectorType: 'gitlab', + permissions: input.permissionConfig, + }) + const prepared = change.permissions + if (!prepared) return undefined + return { + requiresAclReset: prepared.rewriteAccess, + requiresContentSync: prepared.rewriteAccess || Boolean(change.encryptedApiKey), + encryptedApiKey: change.encryptedApiKey, + populateSyncContext(context, connectorId) { + if (prepared.snapshot.mode === 'csv') + setGitLabCsvContext(context, { connectorId, ...prepared.snapshot }) + }, + write: (tx, connectorId) => writeGitLabPermissions(tx, connectorId, prepared), + } + }, + async readSummaries(connectorIds) { + const summaries = await readGitLabPermissionSummaries(connectorIds) + return new Map( + connectorIds.map((id) => [ + id, + { + provider: 'gitlab' as const, + ...(summaries.get(id) ?? { + mode: 'administrator' as const, + revision: 0, + userMapping: null, + projectPermissions: null, + }), + }, + ]) + ) + }, + populateSyncContext: seedGitLabCsvContext, +} diff --git a/apps/sim/connectors/gitlab/permission-config/fields.test.tsx b/apps/sim/connectors/gitlab/permission-config/fields.test.tsx new file mode 100644 index 00000000000..ffda8f92c41 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/fields.test.tsx @@ -0,0 +1,240 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode, useState } from 'react' +import { ChipModal, ChipModalBody, ChipModalField, ChipModalHeader } from '@sim/emcn' +import { sleep } from '@sim/utils/helpers' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + GitLabPermissionData, + GitLabPermissionUploadInput, +} from '@/lib/api/contracts/knowledge/gitlab-permissions' +import { + GitLabPermissionTabs, + GitLabPermissionUploads, +} from '@/connectors/gitlab/permission-config/fields' +import { useGitLabPermissionForm } from '@/connectors/gitlab/permission-config/use-permission-form' + +interface HarnessProps { + saved?: GitLabPermissionData + submit: (input: GitLabPermissionUploadInput) => void + disabled?: boolean +} + +function Harness({ saved, submit, disabled }: HarnessProps) { + const form = useGitLabPermissionForm(saved) + const [project, setProject] = useState('group/project') + return ( + {}} srTitle='Configure GitLab'> + Configure GitLab + + + + + + + + + ) +} + +let root: Root +let container: HTMLDivElement +async function render(element: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => { + root.render(element) + }) +} +function button(label: string): HTMLButtonElement { + const found = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} +function input(label: string): HTMLInputElement { + const labelled = document.querySelector(`input[aria-label="${label}"]`) + if (labelled) return labelled + const fieldLabel = Array.from(document.querySelectorAll('label')).find( + (node) => node.textContent === label + ) + const node = fieldLabel ? document.getElementById(fieldLabel.htmlFor) : null + if (!(node instanceof HTMLInputElement)) throw new Error(`Missing input: ${label}`) + return node +} +async function click(label: string) { + await act(async () => { + button(label).click() + }) +} +async function change(label: string, value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input(label), + value + ) + input(label).dispatchEvent(new Event('input', { bubbles: true })) + }) +} +async function upload(label: string, content: string, filename = 'upload.csv') { + const file = new File([content], filename, { type: 'text/csv' }) + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new TextEncoder().encode(content).buffer, + }) + await act(async () => { + Object.defineProperty(input(label), 'files', { value: [file], configurable: true }) + input(label).dispatchEvent(new Event('change', { bubbles: true })) + }) +} +afterEach(async () => { + if (root) await act(async () => root.unmount()) + container?.remove() + vi.restoreAllMocks() +}) + +describe('GitLab permission setup modal', () => { + it('preserves shared inputs across keyboard tab changes and requires both uploads', async () => { + const submit = vi.fn() + await render() + await change('Personal Access Token', 'fixture-pat') + await change('Project', 'team/docs') + const admin = button('Administrator token') + admin.focus() + await act(async () => { + admin.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + await sleep(1) + }) + expect(button('Non-admin token').getAttribute('aria-checked')).toBe('true') + expect(button('Save').disabled).toBe(true) + await upload('User mapping', 'user_id,email\n1,alice@example.com') + await upload('Project permissions', 'team/docs,1') + expect(button('Save').disabled).toBe(false) + await click('Administrator token') + await click('Non-admin token') + expect(input('Project').value).toBe('team/docs') + expect(input('Personal Access Token').value).toBe('fixture-pat') + await click('Save') + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'gitlab', + mode: 'csv', + userMapping: expect.objectContaining({ + content: expect.stringContaining('alice@example.com'), + }), + projectPermissions: expect.objectContaining({ content: 'team/docs,1' }), + }) + ) + }) + + it('shows inline validation and supplies both exact CSV templates', async () => { + const templates: string[] = [] + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () { + templates.push(decodeURIComponent(this.href.split(',')[1])) + }) + await render() + await click('Non-admin token') + expect(document.body.textContent).not.toContain('CSV · Up to') + const fieldLabel = Array.from(document.querySelectorAll('label')).find((node) => + node.textContent?.startsWith('User mapping') + )! + expect(fieldLabel.querySelector('button')).toBeNull() + await act(async () => fieldLabel.click()) + expect(templates).toEqual([]) + for (const node of document.querySelectorAll('button')) + if (node.textContent === 'Download template') await act(async () => node.click()) + expect(templates).toEqual([ + 'user_id,email\n123,alice@example.com\n', + 'project_path,user_id\ngroup/project,123\n', + ]) + await upload('User mapping', '1,a@example.com\n1,b@example.com') + expect(document.body.textContent).toContain('conflicts with another identity mapping') + expect(button('Save').disabled).toBe(true) + expect(document.body.textContent).toContain('Confidential issues are excluded') + }) + + it('replaces one saved file and submits the revision without resending the other file', async () => { + const submit = vi.fn() + await render( + + ) + expect(document.body.textContent).toContain('saved-projects.csv') + expect(document.body.textContent).toContain('3 rows · Saved') + await upload('User mapping', '1,a@example.com\n1,b@example.com', 'replacement.csv') + expect(button('Save').disabled).toBe(true) + expect(document.body.textContent).toContain('saved-users.csv') + expect(submit).not.toHaveBeenCalled() + await upload('User mapping', '1,new@example.com', 'replacement.csv') + expect(document.body.textContent).toMatch(/replacement.csv.*Ready to save/) + expect(input('User mapping').value).toBe('') + await click('Save') + expect(submit).toHaveBeenCalledWith({ + provider: 'gitlab', + mode: 'csv', + expectedRevision: 7, + userMapping: { filename: 'replacement.csv', content: '1,new@example.com' }, + projectPermissions: undefined, + }) + }) + + it('shows validation progress and blocks repeated picks until the file is read', async () => { + await render() + await click('Non-admin token') + let finishReading: (value: ArrayBuffer) => void = () => {} + const file = new File([], 'users.csv', { type: 'text/csv' }) + Object.defineProperty(file, 'arrayBuffer', { + value: () => + new Promise((resolve) => { + finishReading = resolve + }), + }) + await act(async () => { + Object.defineProperty(input('User mapping'), 'files', { value: [file], configurable: true }) + input('User mapping').dispatchEvent(new Event('change', { bubbles: true })) + }) + const uploadButton = input('User mapping').closest('button')! + expect(uploadButton.disabled).toBe(true) + expect(uploadButton.getAttribute('aria-busy')).toBe('true') + expect(document.body.textContent).toContain('Validating…') + expect(input('User mapping').disabled).toBe(true) + await act(async () => finishReading(new TextEncoder().encode('1,alice@example.com').buffer)) + expect(uploadButton.disabled).toBe(false) + expect(uploadButton.textContent).toContain('users.csv') + expect(uploadButton.textContent).toContain('1 row · Ready to save') + }) + + it('disables both file controls while the enclosing form is saving', async () => { + await render() + await click('Non-admin token') + for (const title of ['User mapping', 'Project permissions']) { + expect(input(title).disabled).toBe(true) + expect(input(title).closest('button')!.disabled).toBe(true) + } + }) +}) diff --git a/apps/sim/connectors/gitlab/permission-config/fields.tsx b/apps/sim/connectors/gitlab/permission-config/fields.tsx new file mode 100644 index 00000000000..3c4c17f9336 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/fields.tsx @@ -0,0 +1,102 @@ +'use client' + +import { Chip, ChipModalField, ChipModalTabs } from '@sim/emcn' +import type { GitLabCsvKind } from '@/connectors/gitlab/permission-config/types' +import type { GitLabPermissionForm } from '@/connectors/gitlab/permission-config/use-permission-form' + +const TABS = [ + { value: 'administrator', label: 'Administrator token' }, + { value: 'csv', label: 'Non-admin token' }, +] as const +const FILES = { + userMapping: { + title: 'User mapping', + filename: 'gitlab-users.csv', + template: 'user_id,email\n123,alice@example.com\n', + }, + projectPermissions: { + title: 'Project permissions', + filename: 'gitlab-project-permissions.csv', + template: 'project_path,user_id\ngroup/project,123\n', + }, +} as const + +interface GitLabPermissionFieldsProps { + form: GitLabPermissionForm + disabled?: boolean +} + +export function GitLabPermissionTabs({ form, disabled }: GitLabPermissionFieldsProps) { + return ( +
+ { + if (!disabled && (value === 'administrator' || value === 'csv')) form.setMode(value) + }} + /> +
+ ) +} + +export function GitLabPermissionUploads({ form, disabled }: GitLabPermissionFieldsProps) { + if (form.mode !== 'csv') return null + return ( + <> +

+ Update these files when access changes. Confidential issues are excluded. +

+ {(['userMapping', 'projectPermissions'] as const).map((kind) => ( + + ))} + + ) +} + +interface GitLabCsvFileProps extends GitLabPermissionFieldsProps { + kind: GitLabCsvKind +} + +function GitLabCsvFile({ kind, form, disabled }: GitLabCsvFileProps) { + const definition = FILES[kind] + const selected = form.files[kind] + const saved = form.saved?.[kind] + const filename = selected?.upload?.filename ?? saved?.filename + const count = selected?.rowCount ?? saved?.rowCount + const downloadTemplate = () => { + const link = document.createElement('a') + link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(definition.template)}` + link.download = definition.filename + link.click() + } + return ( + + Download template + + } + required + accept='.csv,text/csv' + disabled={disabled} + loading={selected?.loading} + label={selected?.loading ? 'Validating…' : (filename ?? 'Drop CSV or click to upload')} + error={selected?.error} + description={ + filename && count !== undefined && !selected?.loading + ? `${count.toLocaleString()} ${count === 1 ? 'row' : 'rows'} · ${selected?.upload ? 'Ready to save' : 'Saved'}` + : undefined + } + onChange={(files) => { + if (files[0]) void form.selectFile(kind, files[0]) + }} + /> + ) +} diff --git a/apps/sim/connectors/gitlab/permission-config/parser.test.ts b/apps/sim/connectors/gitlab/permission-config/parser.test.ts new file mode 100644 index 00000000000..1cd929df9eb --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/parser.test.ts @@ -0,0 +1,95 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { gitLabCsvSubjects, parseGitLabCsv } from '@/connectors/gitlab/permission-config/parser' +import { GITLAB_CSV_MAX_BYTES } from '@/connectors/gitlab/permission-config/types' + +describe('GitLab CSV permission uploads', () => { + it('accepts templates, headerless rows, BOM, CRLF, quotes and identical duplicates', () => { + expect( + parseGitLabCsv( + { + filename: 'users.csv', + content: '\uFEFFuser_id,email\r\n"12", "Alice@Example.com"\r\n12,alice@example.com', + }, + 'userMapping' + ) + ).toEqual([['12', 'alice@example.com']]) + expect( + parseGitLabCsv( + { filename: 'projects.csv', content: 'org/group/project,12\norg/group/project,12' }, + 'projectPermissions' + ) + ).toEqual([['org/group/project', '12']]) + }) + it.each([ + ['1,a@example.com\n1,b@example.com', 'conflicts'], + ['1,a@example.com\n2,A@example.com', 'conflicts'], + ['-1,a@example.com', 'user_id'], + ['9007199254740992,a@example.com', 'user_id'], + ['user_id,email\n', 'at least one'], + ['', 'at least one'], + ['1,', 'email'], + ['1,not-an-email', 'email'], + ['1,a@example.com,extra', 'two columns'], + ['1,"a@example.com', 'Malformed CSV'], + ['1,a@example.com\n2', 'Malformed CSV'], + ])('rejects malformed or conflicting mappings without echoing cells: %s', (content, message) => { + expect(() => parseGitLabCsv({ filename: 'users.csv', content }, 'userMapping')).toThrow(message) + try { + parseGitLabCsv({ filename: 'users.csv', content }, 'userMapping') + } catch (error) { + expect(String(error)).not.toContain('a@example.com') + } + }) + it('rejects malformed project rows and unsafe filenames', () => { + for (const content of [ + 'project_path,user_id\n', + '../project,1', + 'group/project,', + 'group/project,no', + ]) { + expect(() => + parseGitLabCsv({ filename: 'projects.csv', content }, 'projectPermissions') + ).toThrow() + } + expect(() => + parseGitLabCsv({ filename: '../users.csv', content: '1,a@example.com' }, 'userMapping') + ).toThrow('filename') + }) + it('requires an exact selected project and mapping; unrelated rows and unknown users grant nothing', () => { + expect( + gitLabCsvSubjects( + [ + ['1', 'a@example.com'], + ['2', 'b@example.com'], + ], + [ + ['group/project', '1'], + ['group/other', '2'], + ['group/project', '99'], + ], + 'group/project' + ) + ).toEqual(['u:a@example.com']) + }) + it('checks UTF-8 bytes, not just string length', () => { + expect(() => + parseGitLabCsv( + { filename: 'users.csv', content: '😀'.repeat(GITLAB_CSV_MAX_BYTES / 4 + 1) }, + 'userMapping' + ) + ).toThrow('4 MiB') + }) + it('accepts 100,000 rows and rejects the next row', () => { + const content = Array.from({ length: 100_000 }, (_, i) => `${i + 1},u${i}@example.com`).join( + '\n' + ) + expect(parseGitLabCsv({ filename: 'users.csv', content }, 'userMapping')).toHaveLength(100_000) + expect(() => + parseGitLabCsv( + { filename: 'users.csv', content: `${content}\n100001,last@example.com` }, + 'userMapping' + ) + ).toThrow('100,000') + }) +}) diff --git a/apps/sim/connectors/gitlab/permission-config/parser.ts b/apps/sim/connectors/gitlab/permission-config/parser.ts new file mode 100644 index 00000000000..c0334ee2732 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/parser.ts @@ -0,0 +1,123 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { parse } from 'csv-parse/browser/esm/sync' +import { + GITLAB_CSV_MAX_BYTES, + GITLAB_CSV_MAX_ROWS, + type GitLabCsvKind, + type GitLabCsvUpload, +} from '@/connectors/gitlab/permission-config/types' + +const USER_ID = /^[1-9]\d{0,15}$/ +const PROJECT_PATH = /^[\p{L}\p{N}_.-]+(?:\/[\p{L}\p{N}_.-]+)+$/u +const HEADERS = { + userMapping: ['user_id', 'email'], + projectPermissions: ['project_path', 'user_id'], +} as const + +export class GitLabCsvValidationError extends Error { + constructor( + readonly field: GitLabCsvKind, + message: string + ) { + super(`${field === 'userMapping' ? 'User mapping' : 'Project permissions'}: ${message}`) + this.name = 'GitLabCsvValidationError' + } +} + +/** Strict, bounded parsing. Diagnostics never include uploaded cell contents. */ +export function parseGitLabCsv(upload: GitLabCsvUpload, kind: GitLabCsvKind): [string, string][] { + const reject = (message: string): never => { + throw new GitLabCsvValidationError(kind, message) + } + if ( + !upload.filename.trim() || + upload.filename.length > 255 || + /[\p{Cc}/\\]/u.test(upload.filename) + ) { + reject('Use a filename of 1–255 characters without path separators.') + } + if (new TextEncoder().encode(upload.content).byteLength > GITLAB_CSV_MAX_BYTES) { + reject('The file must be 4 MiB or smaller.') + } + const rows: [string, string][] = [] + const seen = new Set() + const identities = new Map() + const emails = new Map() + let recordCount = 0 + let firstRecord = true + try { + parse(upload.content, { + bom: true, + trim: true, + skip_empty_lines: true, + max_record_size: 4096, + on_record(record: string[], context: { lines: number }) { + const line = context.lines + if (record.length !== 2) + reject(`Row ending at line ${line} must contain exactly two columns.`) + if (firstRecord) { + firstRecord = false + if (record[0] === HEADERS[kind][0] && record[1] === HEADERS[kind][1]) return null + } + recordCount++ + if (recordCount > GITLAB_CSV_MAX_ROWS) reject('The file must contain at most 100,000 rows.') + const id = kind === 'userMapping' ? record[0] : record[1] + if (!USER_ID.test(id) || !Number.isSafeInteger(Number(id))) { + reject(`Row ending at line ${line} has an invalid user_id; use a positive integer.`) + } + let row: [string, string] + if (kind === 'userMapping') { + const email = normalizeEmail(record[1]) + if (email.length > 254 || !isValidEmailSyntax(email)) + reject(`Row ending at line ${line} has an invalid email.`) + if ( + (identities.has(id) && identities.get(id) !== email) || + (emails.has(email) && emails.get(email) !== id) + ) { + reject(`Row ending at line ${line} conflicts with another identity mapping.`) + } + identities.set(id, email) + emails.set(email, id) + row = [id, email] + } else { + const path = record[0] + if ( + path.length > 1024 || + !PROJECT_PATH.test(path) || + path.split('/').some((part) => part === '.' || part === '..') + ) { + reject(`Row ending at line ${line} has an invalid project_path; use group/project.`) + } + row = [path, id] + } + const key = JSON.stringify(row) + if (!seen.has(key)) { + seen.add(key) + rows.push(row) + } + return null + }, + }) + } catch (error) { + if (error instanceof GitLabCsvValidationError) throw error + reject('Malformed CSV. Check quoting, column counts, and field lengths.') + } + if (rows.length === 0) reject('The file must contain at least one data row.') + return rows +} + +/** Only explicitly mapped users in the selected canonical project receive grants. */ +export function gitLabCsvSubjects( + users: readonly [string, string][], + permissions: readonly [string, string][], + projectPath: string +): string[] { + const identities = new Map(users) + const subjects = new Set() + for (const [path, id] of permissions) { + if (path !== projectPath) continue + const email = identities.get(id) + if (email) subjects.add(`u:${email}`) + } + return [...subjects].sort() +} diff --git a/apps/sim/connectors/gitlab/permission-config/prepare.ts b/apps/sim/connectors/gitlab/permission-config/prepare.ts new file mode 100644 index 00000000000..7147ed900a5 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/prepare.ts @@ -0,0 +1,96 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { decryptApiKey, encryptApiKey } from '@/lib/api-key/crypto' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GitLabCsvValidationError } from '@/connectors/gitlab/permission-config/parser' +import { + loadGitLabPermissionSnapshot, + type PreparedGitLabPermissions, + prepareGitLabPermissions, +} from '@/connectors/gitlab/permission-config/repository' +import type { GitLabPermissionInput } from '@/connectors/gitlab/permission-config/types' +import { validateGitLabCsvToken } from '@/connectors/gitlab/permissions' + +/** Prepares private writes after the application has authorized the canonical connector owner. */ +export async function prepareGitLabPermissionChange(input: { + connectorType: string + accessMode: string + sourceConfig: Record + permissions?: GitLabPermissionInput + apiKey?: string + existing?: { + id: string + encryptedApiKey: string | null + sourceConfig: unknown + } +}): Promise<{ permissions?: PreparedGitLabPermissions; encryptedApiKey?: string }> { + if (input.connectorType !== 'gitlab') { + if (input.permissions || (input.existing && input.apiKey !== undefined)) { + throw new OrchestrationError( + 'validation', + 'GitLab token and CSV settings require a GitLab source with permission-aware access.' + ) + } + return {} + } + if (input.accessMode !== 'admin' && (input.permissions || input.apiKey !== undefined)) { + throw new OrchestrationError( + 'validation', + 'Token and CSV settings require permission-aware access.' + ) + } + const existing = input.existing ? await loadGitLabPermissionSnapshot(input.existing.id) : null + if (input.accessMode !== 'admin' && !existing) return {} + if (!input.permissions && !existing && !input.apiKey) return {} + if ( + input.existing && + (input.permissions || input.apiKey) && + input.permissions?.expectedRevision === undefined + ) { + throw new OrchestrationError( + 'conflict', + 'Reload GitLab settings before saving the token or permissions.' + ) + } + const permissions = input.permissions ?? { + mode: existing?.mode ?? 'administrator', + expectedRevision: existing?.revision ?? 0, + } + const previousConfig = input.existing?.sourceConfig as Record | undefined + const sameProject = + existing && + previousConfig && + previousConfig.host === input.sourceConfig.host && + previousConfig.project === input.sourceConfig.project + const project = + sameProject && !input.apiKey && existing.mode === permissions.mode + ? { host: existing.host, projectId: existing.projectId, projectPath: existing.projectPath } + : await validateGitLabCsvToken( + input.apiKey ?? + (input.existing?.encryptedApiKey + ? (await decryptApiKey(input.existing.encryptedApiKey)).decrypted + : ''), + input.sourceConfig + ).catch((error: unknown) => { + throw new OrchestrationError( + 'validation', + getErrorMessage(error, 'Unable to validate the GitLab token and project.') + ) + }) + try { + return { + permissions: prepareGitLabPermissions( + permissions, + project, + existing, + Boolean(input.existing) + ), + ...(input.existing && input.apiKey + ? { encryptedApiKey: (await encryptApiKey(input.apiKey)).encrypted } + : {}), + } + } catch (error) { + if (error instanceof GitLabCsvValidationError) + throw new OrchestrationError('validation', error.message) + throw error + } +} diff --git a/apps/sim/connectors/gitlab/permission-config/repository.ts b/apps/sim/connectors/gitlab/permission-config/repository.ts new file mode 100644 index 00000000000..bb012b5c4e8 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/repository.ts @@ -0,0 +1,201 @@ +import { isPlainRecord } from '@sim/utils/object' +import { z } from 'zod' +import { gitLabPermissionSummarySchema } from '@/lib/api/contracts/knowledge/gitlab-permissions' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + loadConnectorPermissionSnapshot, + readConnectorPermissionMetadata, + writeConnectorPermissions, +} from '@/lib/knowledge/connectors/permission-store' +import { gitLabCsvSubjects, parseGitLabCsv } from '@/connectors/gitlab/permission-config/parser' +import { + GITLAB_CSV_MAX_ROWS, + type GitLabPermissionInput, + type GitLabPermissionSummary, + setGitLabCsvContext, +} from '@/connectors/gitlab/permission-config/types' + +const metadataSchema = gitLabPermissionSummarySchema + .omit({ provider: true, revision: true }) + .extend({ + host: z.string().min(1), + projectId: z.number().int().positive(), + projectPath: z.string().min(1), + }) +type Snapshot = z.output & { + connectorId: string + revision: number + userMappingRows: [string, string][] | null + projectPermissionRows: [string, string][] | null +} + +export interface PreparedGitLabPermissions { + expectedRevision: number + snapshot: Omit + subjects: string[] + rewriteAccess: boolean +} + +function invalidSnapshot(): never { + throw new OrchestrationError('internal', 'Could not read the stored GitLab permissions.') +} + +function readMetadata(value: unknown) { + const parsed = metadataSchema.safeParse(value) + if (!parsed.success) return invalidSnapshot() + const result = parsed.data + if ( + result.mode === 'csv' + ? !result.userMapping || !result.projectPermissions + : result.userMapping !== null || result.projectPermissions !== null + ) + return invalidSnapshot() + return result +} + +/** Validate stored tuples without allocating another copy of a large CSV snapshot. */ +function readRows(value: unknown): [string, string][] | null { + if (value === null) return null + const isRow = (row: unknown): row is [string, string] => + Array.isArray(row) && row.length === 2 && row.every((cell) => typeof cell === 'string') + if (!Array.isArray(value) || value.length > GITLAB_CSV_MAX_ROWS || !value.every(isRow)) + return invalidSnapshot() + return value +} + +/** Private contents are loaded only when preparing an authorized replacement. */ +export async function loadGitLabPermissionSnapshot(connectorId: string): Promise { + const row = await loadConnectorPermissionSnapshot(connectorId) + if (!row) return null + const metadata = readMetadata(row.metadata) + if (!isPlainRecord(row.payload)) return invalidSnapshot() + const userMappingRows = readRows(row.payload.userMappingRows) + const projectPermissionRows = readRows(row.payload.projectPermissionRows) + if ( + metadata.mode === 'csv' + ? !userMappingRows || + !projectPermissionRows || + metadata.userMapping?.rowCount !== userMappingRows.length || + metadata.projectPermissions?.rowCount !== projectPermissionRows.length + : userMappingRows !== null || projectPermissionRows !== null + ) + return invalidSnapshot() + return { + connectorId, + revision: row.revision, + ...metadata, + userMappingRows, + projectPermissionRows, + } +} + +export function prepareGitLabPermissions( + input: GitLabPermissionInput, + project: { host: string; projectId: number; projectPath: string }, + existing: Snapshot | null, + replacing: boolean +): PreparedGitLabPermissions { + const revision = existing?.revision ?? 0 + if (replacing && input.expectedRevision !== revision) { + throw new OrchestrationError( + 'conflict', + 'GitLab permissions changed. Reload the connection before saving.' + ) + } + if (input.mode === 'administrator' && (input.userMapping || input.projectPermissions)) { + throw new OrchestrationError('validation', 'CSV uploads require Non-admin token mode.') + } + const now = new Date().toISOString() + const file = (kind: 'userMapping' | 'projectPermissions') => { + const upload = input[kind] + if (!upload) { + const metadata = existing?.mode === 'csv' ? existing[kind] : null + const rows = + kind === 'userMapping' ? existing?.userMappingRows : existing?.projectPermissionRows + return metadata && rows ? { ...metadata, rows } : null + } + return { filename: upload.filename.trim(), uploadedAt: now, rows: parseGitLabCsv(upload, kind) } + } + const userMapping = input.mode === 'csv' ? file('userMapping') : null + const projectPermissions = input.mode === 'csv' ? file('projectPermissions') : null + if (input.mode === 'csv' && (!userMapping || !projectPermissions)) { + throw new OrchestrationError('validation', 'Non-admin token mode requires both CSV files.') + } + return { + expectedRevision: revision, + snapshot: { + ...project, + mode: input.mode, + revision: revision + 1, + userMapping: userMapping + ? { + filename: userMapping.filename, + uploadedAt: userMapping.uploadedAt, + rowCount: userMapping.rows.length, + } + : null, + projectPermissions: projectPermissions + ? { + filename: projectPermissions.filename, + uploadedAt: projectPermissions.uploadedAt, + rowCount: projectPermissions.rows.length, + } + : null, + userMappingRows: userMapping?.rows ?? null, + projectPermissionRows: projectPermissions?.rows ?? null, + }, + subjects: + userMapping && projectPermissions + ? gitLabCsvSubjects(userMapping.rows, projectPermissions.rows, project.projectPath) + : [], + rewriteAccess: + replacing && + ((existing?.mode ?? 'administrator') !== input.mode || + (existing !== null && + (existing.host !== project.host || existing.projectId !== project.projectId))), + } +} + +/** The shared store serializes revision checks and grants inside the owning connector transaction. */ +export async function writeGitLabPermissions( + tx: DbTransaction, + connectorId: string, + prepared: PreparedGitLabPermissions +): Promise { + const { + revision: _revision, + userMappingRows, + projectPermissionRows, + ...metadata + } = prepared.snapshot + await writeConnectorPermissions(tx, connectorId, { + expectedRevision: prepared.expectedRevision, + metadata, + payload: { userMappingRows, projectPermissionRows }, + groups: [{ groupKey: 'project', subjects: prepared.subjects }], + }) +} + +/** Projects only safe metadata; CSV contents never reach a response or browser draft. */ +export async function readGitLabPermissionSummaries( + connectorIds: readonly string[] +): Promise> { + const result = new Map() + for (const row of await readConnectorPermissionMetadata(connectorIds)) { + const { mode, userMapping, projectPermissions } = readMetadata(row.metadata) + result.set(row.connectorId, { mode, revision: row.revision, userMapping, projectPermissions }) + } + return result +} + +/** Workers load canonical mode without loading the potentially large CSV snapshot. */ +export async function seedGitLabCsvContext( + connectorId: string, + context: Record +): Promise { + const [row] = await readConnectorPermissionMetadata([connectorId]) + if (!row) return + const { mode, host, projectId, projectPath } = readMetadata(row.metadata) + if (mode === 'csv') setGitLabCsvContext(context, { connectorId, host, projectId, projectPath }) +} diff --git a/apps/sim/connectors/gitlab/permission-config/types.ts b/apps/sim/connectors/gitlab/permission-config/types.ts new file mode 100644 index 00000000000..0ae990937c7 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/types.ts @@ -0,0 +1,62 @@ +import { connectorPermissionGroupToken } from '@/lib/knowledge/connectors/permission-tokens' + +export const GITLAB_CSV_MAX_BYTES = 4 * 1024 * 1024 +export const GITLAB_CSV_MAX_ROWS = 100_000 +export const GITLAB_PERMISSION_MODES = ['administrator', 'csv'] as const + +export type GitLabPermissionMode = (typeof GITLAB_PERMISSION_MODES)[number] +export type GitLabCsvKind = 'userMapping' | 'projectPermissions' + +export interface GitLabCsvUpload { + filename: string + content: string +} + +export interface GitLabPermissionInput { + mode: GitLabPermissionMode + /** Required on replacement; zero identifies a legacy administrator connection. */ + expectedRevision?: number + userMapping?: GitLabCsvUpload + projectPermissions?: GitLabCsvUpload +} + +export interface GitLabCsvFileSummary { + filename: string + uploadedAt: string + rowCount: number +} + +export interface GitLabPermissionSummary { + mode: GitLabPermissionMode + revision: number + userMapping: GitLabCsvFileSummary | null + projectPermissions: GitLabCsvFileSummary | null +} + +/** Contains no user identities; only server-loaded configuration may populate this context. */ +export interface GitLabCsvSyncContext { + connectorId: string + host: string + projectId: number + projectPath: string +} + +const syncContexts = new WeakMap, GitLabCsvSyncContext>() + +export function setGitLabCsvContext( + context: Record, + configuration: GitLabCsvSyncContext +): void { + syncContexts.set(context, configuration) +} + +export function getGitLabCsvContext( + context?: Record +): GitLabCsvSyncContext | undefined { + return context ? syncContexts.get(context) : undefined +} + +/** A connector-local audience prevents another connector's CSV from granting access. */ +export function gitLabCsvGroupToken(connectorId: string): string { + return connectorPermissionGroupToken(connectorId, 'project') +} diff --git a/apps/sim/connectors/gitlab/permission-config/use-permission-form.ts b/apps/sim/connectors/gitlab/permission-config/use-permission-form.ts new file mode 100644 index 00000000000..60437b22259 --- /dev/null +++ b/apps/sim/connectors/gitlab/permission-config/use-permission-form.ts @@ -0,0 +1,99 @@ +'use client' + +import { useCallback, useMemo, useRef, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import type { + GitLabPermissionData, + GitLabPermissionUploadInput, +} from '@/lib/api/contracts/knowledge/gitlab-permissions' +import { parseGitLabCsv } from '@/connectors/gitlab/permission-config/parser' +import { + GITLAB_CSV_MAX_BYTES, + type GitLabCsvKind, +} from '@/connectors/gitlab/permission-config/types' + +interface SelectedFile { + upload?: { filename: string; content: string } + rowCount?: number + error?: string + loading?: boolean +} + +/** PATs and uploaded content stay in ephemeral component state, never in persisted setup drafts. */ +export function useGitLabPermissionForm(initial?: GitLabPermissionData) { + const generations = useRef({ userMapping: 0, projectPermissions: 0 }) + const [saved, setSaved] = useState(initial) + const [mode, setMode] = useState( + initial?.mode ?? 'administrator' + ) + const [files, setFiles] = useState>>({}) + const [apiKey, setApiKey] = useState('') + const complete = + mode === 'administrator' || + (['userMapping', 'projectPermissions'] as const).every( + (kind) => + !files[kind]?.error && + !files[kind]?.loading && + Boolean(files[kind]?.upload || saved?.[kind]) + ) + const dirty = + mode !== (saved?.mode ?? 'administrator') || + Boolean(apiKey) || + (mode === 'csv' && + Object.values(files).some((file) => file.upload || file.error || file.loading)) + const input = useMemo( + () => ({ + provider: 'gitlab', + mode, + ...(saved ? { expectedRevision: saved.revision } : {}), + ...(mode === 'csv' + ? { + userMapping: files.userMapping?.upload, + projectPermissions: files.projectPermissions?.upload, + } + : {}), + }), + [files, mode, saved] + ) + const selectFile = useCallback(async (kind: GitLabCsvKind, file: File) => { + const generation = ++generations.current[kind] + setFiles((previous) => ({ ...previous, [kind]: { loading: true } })) + let selected: SelectedFile + try { + if (file.size > GITLAB_CSV_MAX_BYTES) throw new Error('The file must be 4 MiB or smaller.') + const content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer()) + const upload = { filename: file.name, content } + const rows = parseGitLabCsv(upload, kind) + selected = { upload, rowCount: rows.length } + } catch (error) { + selected = { + error: getErrorMessage(error, 'Could not read this CSV. Save it as UTF-8 and try again.'), + } + } + if (generation === generations.current[kind]) + setFiles((previous) => ({ ...previous, [kind]: selected })) + }, []) + const reset = useCallback((next?: GitLabPermissionData) => { + generations.current.userMapping++ + generations.current.projectPermissions++ + setFiles({}) + setApiKey('') + setSaved(next) + setMode(next?.mode ?? 'administrator') + }, []) + return { + mode, + setMode, + saved, + files, + apiKey, + setApiKey, + input, + complete, + dirty, + selectFile, + reset, + } +} + +export type GitLabPermissionForm = ReturnType diff --git a/apps/sim/connectors/gitlab/permissions.ts b/apps/sim/connectors/gitlab/permissions.ts index e19f3fb9885..51fc4c0f503 100644 --- a/apps/sim/connectors/gitlab/permissions.ts +++ b/apps/sim/connectors/gitlab/permissions.ts @@ -2,6 +2,10 @@ import { normalizeEmail } from '@sim/utils/string' import { groupToken, sortAccessTokens, userToken } from '@/lib/knowledge/access/tokens' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { + getGitLabCsvContext, + gitLabCsvGroupToken, +} from '@/connectors/gitlab/permission-config/types' import { discoverGitLabPermissionPolicy, type GitLabSourcePolicy, @@ -18,6 +22,7 @@ type Feature = (typeof FEATURES)[number] export interface GitLabPermissionProject { id: number + path_with_namespace?: string visibility: 'private' | 'internal' | 'public' repository_access_level?: string merge_requests_access_level?: string @@ -27,6 +32,27 @@ export interface GitLabPermissionProject { shared_with_groups?: Array<{ group_id: number }> } +/** CSV access is managed by Sim administrators, so only identity and project reads are needed. */ +export async function validateGitLabCsvToken( + token: string, + sourceConfig: Record +): Promise<{ host: string; projectId: number; projectPath: string }> { + const { base, project } = sourceAddress(sourceConfig) + const viewer = await read(`${base}/user`, token, true) + if (viewer.state !== 'active' || viewer.locked === true || !Number.isSafeInteger(viewer.id)) { + throw new Error('An active GitLab identity with read_api access is required') + } + const record = await read(`${base}/projects/${project}`, token, true) + if (!Number.isSafeInteger(record.id) || record.id <= 0 || !record.path_with_namespace) { + throw new Error('GitLab did not return the project identity') + } + return { + host: normalizeGitLabHost(sourceConfig.host), + projectId: record.id, + projectPath: record.path_with_namespace, + } +} + export interface GitLabPermissionUser { id: number email?: string @@ -290,6 +316,15 @@ export async function openGitLabDirectory( config: Record, context?: Record ): Promise { + const csv = getGitLabCsvContext(context) + if (csv) { + return { + providerId: 'gitlab', + tenantId: `csv-${csv.connectorId}`, + listGroups: async () => [], + listGroupMembers: async (group) => ({ group, memberTokens: [], complete: true }), + } + } const { tenant, base, project } = sourceAddress(config) const identity = await read<{ id: number }>(`${base}/projects/${project}`, token) if (!Number.isInteger(identity.id)) throw new Error('GitLab did not return a project identity') @@ -340,6 +375,18 @@ export async function getGitLabDocumentAcls( documents: readonly ExternalDocument[], context?: Record ): Promise> { + const csv = getGitLabCsvContext(context) + if (csv) { + const result: Record = {} + for (const doc of documents) { + const known = /^(file|wiki|issue|merge_request):/.test(doc.externalId) + const excluded = + doc.skippedReason || + (doc.externalId.startsWith('issue:') && doc.metadata?.confidential !== false) + result[doc.externalId] = known && !excluded ? [gitLabCsvGroupToken(csv.connectorId)] : [] + } + return result + } const state = await snapshot(token, config, context) const { tenant } = sourceAddress(config) const users = new Map(state.users.map((person) => [person.id, person])) diff --git a/apps/sim/connectors/google-drive/google-drive-errors.ts b/apps/sim/connectors/google-drive/google-drive-errors.ts index b57cd059b27..db9c993b5e5 100644 --- a/apps/sim/connectors/google-drive/google-drive-errors.ts +++ b/apps/sim/connectors/google-drive/google-drive-errors.ts @@ -20,7 +20,18 @@ const PERMISSION_REASONS = new Set([ 'insufficientFilePermissions', 'teamDriveMembershipRequired', ]) -const POLICY_REASONS = new Set(['domainPolicy', 'download_restricted_for_revision']) +/** + * Owner- or admin-imposed restrictions on an otherwise readable file. The + * credential is valid, so these are not authorization failures; `cannotExportFile` + * and `cannotDownloadFile` are what Drive returns when the owner disabled + * download, print, and copy for viewers. + */ +const POLICY_REASONS = new Set([ + 'domainPolicy', + 'download_restricted_for_revision', + 'cannotDownloadFile', + 'cannotExportFile', +]) const UNSUPPORTED_EXPORT_REASONS = new Set(['fileNotDownloadable', 'fileNotExportable']) const QUOTA_REASONS = new Set(['dailyLimitExceeded', 'quotaExceeded']) const RATE_LIMIT_REASONS = new Set([ diff --git a/apps/sim/connectors/google-drive/google-drive.test.ts b/apps/sim/connectors/google-drive/google-drive.test.ts index f538e5b81bd..06444b02cf4 100644 --- a/apps/sim/connectors/google-drive/google-drive.test.ts +++ b/apps/sim/connectors/google-drive/google-drive.test.ts @@ -41,7 +41,10 @@ afterEach(() => { vi.unstubAllGlobals() }) -import { googleDriveConnector } from '@/connectors/google-drive/google-drive' +import { + DOWNLOAD_RESTRICTED_SKIP_REASON, + googleDriveConnector, +} from '@/connectors/google-drive/google-drive' import { GoogleDriveApiError, readGoogleDriveApiError, @@ -458,6 +461,8 @@ describe('Google Drive API error parsing', () => { ['insufficientFilePermissions', 'permission'], ['appNotAuthorizedToFile', 'permission'], ['domainPolicy', 'policy'], + ['cannotDownloadFile', 'policy'], + ['cannotExportFile', 'policy'], ['fileNotExportable', 'unsupported_export'], ['dailyLimitExceeded', 'quota'], ['rateLimitExceeded', 'transient'], @@ -564,6 +569,72 @@ describe('Google Drive API error parsing', () => { }) }) +describe('Google Drive download-restricted files', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + const restricted = () => fileMetadata({ capabilities: { canDownload: false } }) + + it('always asks Drive whether the file can be downloaded', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ files: [] })) + await googleDriveConnector.listDocuments('token', {}, undefined, {}) + + const fields = decodeURIComponent(String(mockFetch.mock.calls[0][0])) + expect(fields).toContain('capabilities(canDownload)') + expect(fields).not.toContain('permissions(') + }) + + it.each([ + ['a workspace crawl', {}], + ['a per-member listing', { perMemberListing: true }], + ])('skips a restricted file at listing time in %s', async (_label, syncContext) => { + mockFetch.mockResolvedValueOnce(jsonResponse({ files: [restricted()] })) + const page = await googleDriveConnector.listDocuments('token', {}, undefined, syncContext) + + expect(page.documents).toHaveLength(1) + expect(page.documents[0].skippedReason).toBe(DOWNLOAD_RESTRICTED_SKIP_REASON) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('lists a downloadable file as an ordinary deferred stub', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ files: [fileMetadata({ capabilities: { canDownload: true } })] }) + ) + const page = await googleDriveConnector.listDocuments('token', {}, undefined, {}) + + expect(page.documents[0].skippedReason).toBeUndefined() + expect(page.documents[0].contentDeferred).toBe(true) + }) + + it('skips hydration without calling export when metadata says the file is restricted', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(restricted())) + const document = await googleDriveConnector.getDocument('token', {}, FILE_ID) + + expect(document?.skippedReason).toBe(DOWNLOAD_RESTRICTED_SKIP_REASON) + expect(document?.contentDeferred).toBe(false) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('reports a restricted file from the change feed as a skipped upsert', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + changes: [{ changeType: 'file', fileId: FILE_ID, file: restricted() }], + newStartPageToken: '2', + }) + ) + const page = await googleDriveConnector.listChanges?.('token', {}, '1', {}) + + expect(page?.changes).toHaveLength(1) + const change = page?.changes[0] + expect(change?.kind).toBe('upsert') + if (change?.kind === 'upsert') { + expect(change.document.skippedReason).toBe(DOWNLOAD_RESTRICTED_SKIP_REASON) + } + }) +}) + describe('Google Drive metadata hydration', () => { beforeEach(() => { vi.clearAllMocks() @@ -606,6 +677,8 @@ describe('Google Drive export failures', () => { 403, ], ['domainPolicy', 'The domain administrators have disabled Drive apps.', 403], + ['cannotExportFile', 'This file cannot be exported by the user.', 403], + ['cannotDownloadFile', 'This file cannot be downloaded by the user.', 403], ['fileNotExportable', 'This file cannot be exported.', 403], ])( 'propagates recoverable %s failures instead of persisting a sticky same-hash skip', diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index fdca1d94b87..88d9413f5a4 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -73,7 +73,21 @@ const SHORTCUT_FETCH_CONCURRENCY = 8 const DRIVE_METADATA_MAX_BYTES = 1024 * 1024 const DRIVE_PAGE_MAX_BYTES = 16 * 1024 * 1024 const DRIVE_FILE_FIELDS = - 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,trashed,parents,shortcutDetails(targetId,targetMimeType,targetResourceKey)' + 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,trashed,parents,shortcutDetails(targetId,targetMimeType,targetResourceKey),capabilities(canDownload)' + +/** + * Recorded on a listed file whose owner disabled download, print, and copy for + * viewers. Drive rejects both `files.export` and `files.get?alt=media` for such a + * file (`cannotExportFile` / `cannotDownloadFile`), so the crawl skips the fetch + * and surfaces the restriction instead of failing hydration on every sync. + */ +export const DOWNLOAD_RESTRICTED_SKIP_REASON = + 'The file owner has disabled downloading for viewers, so its content cannot be indexed' + +/** True when the file's metadata says the acting credential cannot read its bytes. */ +function isDownloadRestricted(file: DriveFile): boolean { + return file.capabilities?.canDownload === false +} const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' @@ -427,6 +441,13 @@ function driveChangeToExternal( if (change.removed || !file || !isFileInScope(file, sourceConfig)) { return { kind: 'removed', externalId } } + if (isDownloadRestricted(file)) { + return { + kind: 'upsert', + externalId, + document: markSkipped(fileToStub(file), DOWNLOAD_RESTRICTED_SKIP_REASON), + } + } return { kind: 'upsert', externalId, @@ -731,7 +752,7 @@ async function readDriveFile( resourceKey?: string, permissions = false ): Promise { - const fields = `${DRIVE_FILE_FIELDS}${permissions ? `,permissions(${DRIVE_PERMISSION_FIELDS}),capabilities(canDownload)` : ''}` + const fields = `${DRIVE_FILE_FIELDS}${permissions ? `,permissions(${DRIVE_PERMISSION_FIELDS})` : ''}` const response = await fetchGoogleDriveWithRetry( `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true`, { method: 'GET', headers: driveRequestHeaders(accessToken, fileId, resourceKey) } @@ -860,6 +881,8 @@ async function listedFileToDocument( } const contentFile = target ?? file if (!matchesFileType((sourceConfig.fileType as string) || 'all', contentFile)) return null + if (isDownloadRestricted(contentFile)) + return markSkipped(fileToStub(file, acl, target), DOWNLOAD_RESTRICTED_SKIP_REASON) return stubOrSkipBySize( fileToStub(file, acl, target), Number(contentFile.size) || undefined, @@ -1069,7 +1092,7 @@ const listGoogleDriveDocuments: ConnectorConfig['listDocuments'] = async ( * crawl would pull a permission array per file and discard it. */ fields: `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS}${ - aclContext ? `,permissions(${DRIVE_PERMISSION_FIELDS}),capabilities(canDownload)` : '' + aclContext ? `,permissions(${DRIVE_PERMISSION_FIELDS})` : '' })`, supportsAllDrives: 'true', includeItemsFromAllDrives: 'true', @@ -1252,6 +1275,10 @@ export const googleDriveConnector: ConnectorConfig = { } } + if (isDownloadRestricted(contentFile)) { + return markSkipped(stub, DOWNLOAD_RESTRICTED_SKIP_REASON) + } + try { const payload = await fetchFilePayload(accessToken, contentFile, resourceKey) if (!payload.content.trim() && !payload.sourceFile?.bytes.length) { diff --git a/apps/sim/connectors/jira/meta.ts b/apps/sim/connectors/jira/meta.ts index 43f05f39fa3..d99f48cf0d6 100644 --- a/apps/sim/connectors/jira/meta.ts +++ b/apps/sim/connectors/jira/meta.ts @@ -16,7 +16,7 @@ export const jiraConnectorMeta: ConnectorMeta = { configFields: [ { id: 'domain', - title: 'Jira Domain', + title: 'Jira site', type: 'short-input', placeholder: 'yoursite.atlassian.net', required: true, @@ -29,6 +29,8 @@ export const jiraConnectorMeta: ConnectorMeta = { canonicalParamId: 'projectKey', mode: 'basic', multi: true, + allowSelectAll: true, + preserveValueOnModeChange: true, dependsOn: ['domain'], placeholder: 'Select one or more projects', required: true, diff --git a/apps/sim/connectors/slack/meta.ts b/apps/sim/connectors/slack/meta.ts index ab12eabea11..b86a2944def 100644 --- a/apps/sim/connectors/slack/meta.ts +++ b/apps/sim/connectors/slack/meta.ts @@ -38,6 +38,7 @@ export const slackConnectorMeta: ConnectorMeta = { id: 'includeChannels', title: 'Channel Messages', type: 'dropdown', + placeholder: 'Include (default)', required: false, options: [ { label: 'Include (default)', id: 'true' }, @@ -48,6 +49,7 @@ export const slackConnectorMeta: ConnectorMeta = { id: 'includeDirectMessages', title: 'Direct Messages', type: 'dropdown', + placeholder: 'Exclude (default)', required: false, description: 'Includes one-to-one and group DMs accessible to the connected account. Requires im:read, im:history, mpim:read, and mpim:history on the member’s grant.', @@ -96,6 +98,7 @@ export const slackConnectorMeta: ConnectorMeta = { setupGroup: 'options', title: 'Archived Channels', type: 'dropdown', + placeholder: 'Include (default)', required: false, options: [ { label: 'Include (default)', id: 'true' }, diff --git a/apps/sim/connectors/slack/slack.test.ts b/apps/sim/connectors/slack/slack.test.ts index 84a4f8ee54d..3353e691137 100644 --- a/apps/sim/connectors/slack/slack.test.ts +++ b/apps/sim/connectors/slack/slack.test.ts @@ -410,6 +410,66 @@ describe('Slack thread indexing through provider APIs', () => { }) }) +describe('Slack threads without indexable text', () => { + beforeEach(() => { + pageSize = 1 + channels = [ + { + channel: GENERAL, + readers: ['alice'], + messages: [{ ...root(''), thread_ts: ROOT }], + replies: { [ROOT]: [{ ...root(''), thread_ts: ROOT }, reply('')] }, + }, + ] + }) + + it('explicitly skips a listed thread only after reading all its reply pages', async () => { + const listed = await listAll('alice') + expect(listed.documents).toHaveLength(1) + const document = await slackConnector.getDocument('alice', {}, id(GENERAL.id), listed.context) + expect(document).toMatchObject({ + externalId: listed.documents[0].externalId, + content: '', + contentDeferred: false, + skippedReason: 'Document contains no extractable text', + skippedExistingDisposition: 'replace', + metadata: { messageCount: 0, rootTs: ROOT, channelId: GENERAL.id, teamId: TEAM }, + }) + expect(calls.filter((call) => call.method === 'conversations.replies')).toHaveLength(2) + expect(calls.some((call) => call.method === 'chat.getPermalink')).toBe(false) + }) + + it('indexes a later reply edit even when the root and reply count have not changed', async () => { + const empty = await slackConnector.getDocument('alice', {}, id(GENERAL.id)) + channels[0].replies[ROOT][1] = reply('Orion has a launch date') + const document = await slackConnector.getDocument('alice', {}, id(GENERAL.id)) + expect(document?.externalId).toBe(empty?.externalId) + expect(document?.content).toContain('Orion has a launch date') + expect(document?.contentHash).not.toBe(empty?.contentHash) + expect(document?.skippedReason).toBeUndefined() + }) + + it('does not classify a missing root as verified empty content', async () => { + replacement = (call) => + call.method === 'conversations.replies' ? { ok: true, messages: [] } : undefined + expect(await slackConnector.getDocument('alice', {}, id(GENERAL.id))).toBeNull() + }) + + it.each([ + [{ ok: true, messages: [reply('')], is_limited: true }, 'only part'], + [{ ok: true, messages: [reply('')], has_more: true }, 'continuation cursor'], + [{ ok: false, error: 'missing_scope' }, 'missing_scope'], + [{ ok: true }, 'invalid message page'], + ])( + 'does not skip an empty thread when a later page is incomplete: %j', + async (response, error) => { + replacement = (call) => + call.method === 'conversations.replies' && call.params.has('cursor') ? response : undefined + await expect(slackConnector.getDocument('alice', {}, id(GENERAL.id))).rejects.toThrow(error) + } + ) +}) + describe('Slack incomplete and unsafe provider responses', () => { it('does not complete a member observation when channel access disappears between history pages', async () => { pageSize = 1 @@ -490,6 +550,31 @@ describe('Slack incomplete and unsafe provider responses', () => { } ) + it.each([ + { code: 'ratelimited', status: 429, category: 'rate_limit' }, + { code: 'token_revoked', status: 401, category: 'authorization' }, + { code: 'missing_scope', status: 403, category: 'authorization' }, + { code: 'internal_error', status: 503, category: 'provider_unavailable' }, + { code: 'invalid_arguments', status: 400, category: 'request_rejected' }, + ])('classifies the $code envelope as HTTP $status $category', async ({ code, ...expected }) => { + failure = (call) => + call.method === 'conversations.replies' ? { ok: false, error: code } : undefined + await expect(slackConnector.getDocument('alice', {}, id(GENERAL.id))).rejects.toMatchObject({ + name: 'SlackApiError', + code, + ...expected, + }) + }) + + it('keeps the HTTP status of a non-OK response for failure classification', async () => { + fetchMock.mockImplementationOnce(async () => new Response('forbidden', { status: 403 })) + await expect(slackConnector.getDocument('alice', {}, id(GENERAL.id))).rejects.toMatchObject({ + name: 'ConnectorSourceError', + status: 403, + message: expect.stringMatching(/^Slack [a-z.]+ failed with HTTP 403$/), + }) + }) + it('propagates Slack envelope throttling so the sync scheduler can cool down', async () => { failure = (call) => call.method === 'conversations.replies' ? { ok: false, error: 'ratelimited' } : undefined diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index 32d3128e181..22c2ace4b29 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -15,11 +15,16 @@ import { readSlackConversationSetting as readConversationSetting, } from '@/connectors/slack/config' import { DEFAULT_MAX_MESSAGES, slackConnectorMeta } from '@/connectors/slack/meta' +import { + ConnectorSourceError, + type ConnectorSourceFailureCategory, +} from '@/connectors/source-error' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { BoundedLines, CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, ConnectorFileTooLargeError, + markSkipped, parseDefaultedUnlimitedSafeInteger, parseMultiValue, parseTagDate, @@ -94,16 +99,70 @@ interface SlackListingCursor { scanned: number } +interface SlackCodeClassification { + /** The HTTP status Slack would have used had it not answered 200 with an error envelope. */ + status: number + category: ConnectorSourceFailureCategory +} + +/** + * Slack answers HTTP 200 with `ok: false` and a machine-readable code. Mapping + * the known codes onto the shared failure categories lets the sync engine and + * the stored document error tell a revoked token from a missing thread from a + * throttle, instead of every envelope error reading as an unknown failure. + */ +const SLACK_CODE_CLASSIFICATIONS: ReadonlyArray<[ReadonlySet, SlackCodeClassification]> = [ + [new Set(['ratelimited']), { status: 429, category: 'rate_limit' }], + [ + new Set(['invalid_auth', 'token_revoked', 'token_expired', 'account_inactive', 'not_authed']), + { status: 401, category: 'authorization' }, + ], + [ + new Set(['missing_scope', 'access_denied', 'restricted_action', 'ekm_access_denied']), + { status: 403, category: 'authorization' }, + ], + [ + new Set([ + 'channel_not_found', + 'not_in_channel', + 'channel_is_limited_access', + 'thread_not_found', + 'message_not_found', + ]), + { status: 404, category: 'source_unavailable' }, + ], + [ + new Set(['service_unavailable', 'internal_error', 'fatal_error', 'request_timeout']), + { status: 503, category: 'provider_unavailable' }, + ], +] + +/** Unknown codes are treated as a rejected request; the status alone drives the diagnostic. */ +const UNCLASSIFIED_SLACK_CODE: SlackCodeClassification = { + status: 400, + category: 'request_rejected', +} + +function classifySlackCode(code: string): SlackCodeClassification { + for (const [codes, classification] of SLACK_CODE_CLASSIFICATIONS) { + if (codes.has(code)) return classification + } + return UNCLASSIFIED_SLACK_CODE +} + /** Slack's HTTP-200 errors still retain their machine-readable provider code. */ -class SlackApiError extends Error { +class SlackApiError extends ConnectorSourceError { + readonly code: string + readonly method: string + readonly headers?: Headers readonly rateLimited: boolean - constructor( - readonly code: string, - readonly method: string, - readonly headers?: Headers - ) { - super(`Slack ${method} failed: ${code}`) + constructor(code: string, method: string, headers?: Headers) { + const { status, category } = classifySlackCode(code) + super(`Slack ${method} failed: ${code}`, status, category) this.name = 'SlackApiError' + this.code = code + this.method = method + this.headers = headers this.rateLimited = code === 'ratelimited' } } @@ -119,7 +178,13 @@ async function slackApiGet( { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, retryOptions ) - if (!response.ok) throw new Error(`Slack ${method} failed with HTTP ${response.status}`) + /** Retries are exhausted by now; the status must survive so the failure can be classified. */ + if (!response.ok) { + throw new ConnectorSourceError( + `Slack ${method} failed with HTTP ${response.status}`, + response.status + ) + } const data = await readResponseJsonWithLimit(response, { maxBytes: MAX_RESPONSE_BYTES, label: `Slack ${method} response`, @@ -693,22 +758,14 @@ async function getDocument( cursor = continuation } if (!exhausted) throw new Error(`Slack thread exceeds ${MAX_THREAD_PAGES} reply pages`) - if (!root || lines.count === 0) return null - const link = await slackApiGet('chat.getPermalink', accessToken, { - channel: channelId, - message_ts: rootTs, - }) - if (typeof link.permalink !== 'string' || !link.permalink.startsWith('https://')) { - throw new Error('Slack did not return a message permalink') - } - const content = lines.join() - return { + if (!root) return null + const content = lines.count > 0 ? lines.join() : '' + const document: ExternalDocument = { externalId, title: messageTitle(channel, root), content, contentDeferred: false, mimeType: 'text/plain', - sourceUrl: link.permalink, contentHash: `slack-content:v4:${createHash('sha256').update(content).digest('hex')}`, metadata: { channelName: channel.name, @@ -720,6 +777,21 @@ async function getDocument( lastActivity: new Date(Number(lastActivity) * 1000).toISOString(), }, } + /** Only a fully read thread can authoritatively replace previously indexed text with a skip. */ + if (lines.count === 0) { + return { + ...markSkipped(document, 'Document contains no extractable text'), + skippedExistingDisposition: 'replace', + } + } + const link = await slackApiGet('chat.getPermalink', accessToken, { + channel: channelId, + message_ts: rootTs, + }) + if (typeof link.permalink !== 'string' || !link.permalink.startsWith('https://')) { + throw new Error('Slack did not return a message permalink') + } + return { ...document, sourceUrl: link.permalink } } catch (error) { if ( error instanceof SlackApiError && diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index d6392a19b1f..dc36523449b 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -1,4 +1,6 @@ import type { MirroredDocumentAcl } from '@/lib/knowledge/access/types' +import type { ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' +import type { ConnectorPermissionConfigCapability } from '@/lib/knowledge/connectors/permission-config' import type { OAuthService } from '@/lib/oauth/types' import type { SelectorKey } from '@/lib/selectors/manifest' @@ -301,6 +303,8 @@ export interface ConnectorConfigField { mode?: 'basic' | 'advanced' /** Links selector + manual input fields that resolve to the same config key */ canonicalParamId?: string + /** Both modes use the same provider identifiers, so switching carries the current selection. */ + preserveValueOnModeChange?: boolean /** * When true, the field accepts multiple values. @@ -309,6 +313,8 @@ export interface ConnectorConfigField { * Connector handlers receive `string | string[]` and should normalize via `parseMultiValue`. */ multi?: boolean + /** Offers explicit bulk selection of the complete, bounded provider list. */ + allowSelectAll?: boolean } /** @@ -321,6 +327,8 @@ export interface ConnectorConfigField { * mirroring the `XBlockMeta` pattern in `blocks/`. */ export interface ConnectorMeta { + /** Restricts new setup and mode changes; existing sources keep their stored access policy. */ + supportedAccessModes?: readonly ConnectorAccessMode[] /** Opts a source into workspace Search after its indexing and permission paths are verified. */ search?: true /** Source setup guide shown only in Search connection flows. */ @@ -425,6 +433,8 @@ export interface ConnectorMeta { * Adding a new connector = creating one of these + registering it. */ export interface ConnectorConfig extends ConnectorMeta { + /** Optional private permission setup, including transactional replacement and worker context. */ + permissionConfig?: ConnectorPermissionConfigCapability /** Bounds local hydration fan-out to avoid queueing siblings behind a serial provider gate. */ contentConcurrency?: 1 | 2 | 3 | 4 | 5 /** diff --git a/apps/sim/content/library/ai-coding-agents-vs-ai-workflow-agents/index.mdx b/apps/sim/content/library/ai-coding-agents-vs-ai-workflow-agents/index.mdx new file mode 100644 index 00000000000..7bf5af022ac --- /dev/null +++ b/apps/sim/content/library/ai-coding-agents-vs-ai-workflow-agents/index.mdx @@ -0,0 +1,103 @@ +--- +slug: ai-coding-agents-vs-ai-workflow-agents +title: 'AI Coding Agents vs. AI Workflow Agents: What''s the Difference?' +description: 'Compare AI coding agents like Cursor, GitHub Copilot, Augment Code, and Devin with AI workflow agents like Sim, Zapier, Make, and n8n across context, task scope, integrations, setup, pricing, and oversight.' +date: 2026-09-10 +updated: 2026-09-10 +authors: + - andrew +readingTime: 7 +tags: [AI Agents, Coding Agents, Workflow Automation, Sim] +ogImage: /library/ai-coding-agents-vs-ai-workflow-agents/cover.jpg +canonical: https://www.sim.ai/library/ai-coding-agents-vs-ai-workflow-agents +draft: false +faq: + - q: "Can a coding agent replace a workflow agent?" + a: "A coding agent edits and tests software inside a codebase. Sim coordinates actions, data, approvals, and integrations across business systems. Choose a coding agent when the deliverable is code and a workflow agent when the deliverable is a completed business process." + - q: "Can a workflow agent write code?" + a: "A workflow agent can execute code as one step within a larger process. Sim places code beside model calls, conditions, integrations, and human approvals in the same graph. You can use custom logic without turning the entire workflow into a software project." + - q: "Which category has a bigger context window?" + a: "A context window measures how much information a model can process in one request. Coding agents emphasize repository context, while Sim draws context from connected files, tables, knowledge bases, and applications. Compare the information each agent can access rather than relying on token limits alone." + - q: "Do I need both types of agent?" + a: "Coding agents modify software, while workflow agents coordinate processes across systems. Sim can execute code within workflows and receive outputs produced by coding agents. You may need both when software changes must trigger or support wider business operations." +--- + +## TL;DR + +- AI coding agents write and modify code inside a codebase, while AI workflow agents orchestrate tasks and data across business systems. +- Cursor, GitHub Copilot, Augment Code, and Devin belong primarily to the coding-agent category. Sim, Zapier, Make, and n8n belong primarily to the workflow-agent category. +- Use the expected output as the test. Choose a coding agent when you need code changes, tests, or a pull request. Choose a workflow agent when you need actions coordinated across applications, APIs, and data stores. + +## Why these two categories keep getting conflated + +These tools are often grouped together because both use language models to plan actions, call tools, and complete multistep tasks. Product descriptions also use shared terms such as agents, automation, and orchestration, even when the tools operate on different objects and serve different jobs. Our guide to [agentic AI coding tools](https://www.sim.ai/library/agentic-ai-coding-tools-what-they-are-and-how-the-top-options-compare) examines the coding side of this distinction in more detail. + +[Microsoft's architecture framework](https://techcommunity.microsoft.com/blog/azurearchitectureblog/building-ai-agents-workflow-first-vs-code-first-vs-hybrid/4466788) provides a useful precedent. It separates workflow-first platforms from code-first frameworks, then identifies hybrid designs that combine visual orchestration with custom code. Hybrid products make labels especially unreliable because either category can borrow capabilities from the other. + +A useful comparison examines the context each agent receives, the systems it can act on, and the evidence you use to verify completion. Context, available actions, and completion evidence provide a more useful classification than interface or branding. + +## 1. Context window vs. context surface + +A context window measures how much text a model can process in one request, usually in tokens. A context surface covers the material an agent can search and retrieve before filling that window. The surface may include an indexed repository, documentation, commit history, and relationships between files. Retrieved excerpts still consume context-window space. + +Cursor expands its context surface by finding relevant code and letting developers reference repository materials. Its [official documentation](https://docs.cursor.com/context/%40-symbols/overview) describes tools for understanding a codebase, tracing how a repository fits together, and finding the right places to make a change. The agent retrieves relevant material instead of treating every file as equally important. + +Augment Code applies a similar retrieval model through its Context Engine. Augment's [Context Connectors documentation](https://docs.augmentcode.com/context-services/context-connectors/how-it-works) describes a pipeline that filters, chunks, embeds, stores, and semantically searches repository content. This describes a searchable context surface, not an unlimited context window. + +[Cognition describes Devin](https://www.cognition.ai/) as planning, writing, testing, and shipping code while working inside a codebase and its development tools. Cognition does not publish a context-window size on that overview, so compare documented repository coverage separately from token limits rather than inferring one measure from the other. + +## 2. Task scope: single repo vs. cross-system process + +A coding agent completes a task by changing a codebase and producing a verifiable software result. The endpoint may be a pull request, a resolved issue, or a passing test suite. For example, the [GitHub Copilot cloud agent](https://code.visualstudio.com/docs/copilot/copilot-cloud-agent) can take a GitHub issue, explore a repository, change multiple files, and run builds, tests, and checks in an isolated environment. + +A workflow agent completes a task when a business process reaches its intended state across connected systems. For example, an agent might classify a support request, update the customer record, and pause for approval before issuing a refund. The task ends when those actions finish or reach a defined exception path. This broader coordination is the focus of [AI agent orchestration](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained). + +The expected output provides the clearest distinction between the categories. Choose a coding agent when you need software work inside a repository. Choose a workflow agent when you need data and actions coordinated across applications, APIs, or internal services. + +## 3. Integration and action surface + +A coding agent's action surface centers on the development environment. [VS Code's Copilot cloud agent](https://code.visualstudio.com/docs/copilot/copilot-cloud-agent) can inspect a codebase, edit files, and run development checks in its own environment. The sources cited here do not document numerical tool-call limits for GitHub Copilot or Cursor, so this article does not compare those limits. + +A workflow agent's action surface spans external applications and APIs. [Sim's pricing page](https://www.sim.ai/pricing) describes access to more than 1,000 integrations. [Zapier publishes its app directory](https://zapier.com/apps), while [Make publishes its integration catalog](https://www.make.com/en/integrations). n8n provides both built-in connectors and a documented [Code node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code) for custom JavaScript or Python within a workflow. + +Catalog totals require careful interpretation. One integration may expose dozens of actions, such as creating a Salesforce record or sending a Slack message. A first-party block, app connector, and individual tool action therefore represent different units. Coding agents suit work performed through editors, repositories, terminals, and test runners. Workflow agents suit processes that must read or change data across several business systems. + +## 4. Setup time and deployment target + +Coding agents usually start inside a development environment. Cursor's [product documentation](https://docs.cursor.com/context/%40-symbols/overview) centers setup and use on understanding, editing, and reviewing a codebase with the developer's existing tools. The work required to prepare a coding agent depends on repository access, project instructions, dependencies, and development permissions. + +Devin uses a different operating model. [Cognition presents Devin](https://www.cognition.ai/) as an autonomous software engineer that works inside a team's codebase and tools. Evaluators should verify current access, repository permissions, and environment requirements instead of treating a product's launch conditions as a present limit. + +The workflow agents compared here use managed services, self-hosted services, or both. Zapier and Make provide web-based automation products through their [pricing](https://zapier.com/pricing) and [product](https://www.make.com/en/product) pages. n8n documents both [n8n Cloud and self-hosted deployment](https://docs.n8n.io/choose-how-to-use-n8n). Sim offers a hosted service and an Apache 2.0-licensed core alongside separately licensed enterprise features; its [self-hosting documentation](https://docs.sim.ai/platform/self-hosting) covers Docker and Kubernetes deployments. + +Fixed setup-time comparisons can mislead because the work varies with repository size, system credentials, and deployment choices. Compare the required starting environment instead. Coding agents need codebase access, while workflow agents need connections to the business systems they will operate. Teams considering deployment tradeoffs can also compare [open-source AI agent frameworks](https://www.sim.ai/library/best-open-source-ai-agent-frameworks). + +## 5. Pricing model + +The coding agents discussed here commonly combine developer access with usage limits or metering. [Cursor's official pricing page](https://cursor.com/pricing) lists a free Hobby plan, an Individual plan at $20 per month, a Teams plan at $40 per user per month, and custom Enterprise pricing. Because software pricing changes, readers should confirm current rates before comparing costs. Model choice and request volume can affect usage. + +Augment Code illustrates why message counts can misrepresent coding work. A short question and a multi-file refactor may each begin with one message but require very different amounts of computing. Augment's [official pricing announcement](https://www.augmentcode.com/blog/augment-codes-pricing-is-changing) explains its move from per-message billing to credits because tasks differ in scope, work, time, and cost. + +The workflow agents compared here meter different units associated with workflow activity. Vendors describe those units on their current pricing pages: [Zapier](https://zapier.com/pricing), [Make](https://www.make.com/en/pricing), [n8n](https://n8n.io/pricing/), and [Sim](https://docs.sim.ai/platform/costs). Make counts module actions as credits, n8n bases pricing on monthly workflow executions, and Sim uses credits for workflow usage. These units are vendor-specific and do not represent the same amount of work. Estimate costs by testing a representative workflow that includes expected loops, agent calls, and retries. + +## 6. Verification and human oversight + +Coding agents usually present code changes for developer review. Cursor's documentation emphasizes inspecting diffs and running checks, while [BugBot reviews pull requests](https://cursor.com/docs/bugbot) for bugs, security issues, and code-quality problems. Neither activity removes the developer's responsibility to verify acceptance criteria through review and tests. + +Workflow agents can place approval and evaluation steps inside the execution path. Sim provides separate Human in the Loop, Guardrails, and Evaluator blocks. The [Human in the Loop documentation](https://docs.sim.ai/workflows/blocks/human-in-the-loop) explains that the block pauses a run without a timeout until someone responds through the approval portal, API, or webhook. Sim's workflow definitions also support notifications to alert approvers. + +Each category places oversight at a different stage of execution. Cursor focuses review on code changes before merge. Sim can require approval while a cross-system workflow is still running, while Guardrails and Evaluator blocks support validation and scoring within the graph. For a broader production perspective, see [AI agent observability](https://www.sim.ai/library/ai-agent-observability). + +## Where the categories overlap + +The categories overlap when an agent crosses the boundary between codebase work and external orchestration. A coding agent may receive work through an issue, modify a repository, and hand the result to another process as a pull request. GitHub Copilot supports this pattern through [cloud agents that work on GitHub issues](https://code.visualstudio.com/docs/copilot/copilot-cloud-agent). Repository events can then trigger testing, review, or deployment workflows. + +A workflow agent can include code execution within a process whose final outcome spans other systems. Sim's Function block executes JavaScript without imports locally, while JavaScript with imports, Python, and shell code require a configured remote sandbox. These modes can run within a larger graph that also contains model reasoning and deterministic steps such as conditions or API calls. The code block serves one stage of a cross-system process rather than treating the repository change as the final output. + +The primary completion target still identifies the category. Coding agents usually finish with validated code or a pull request. Workflow agents finish when the broader business process reaches its defined outcome. + +## Choosing the right category for the job + +Choose according to the artifact you need to change. If the job ends with source files, tests, or a pull request, choose a coding agent. If success requires moving data and triggering actions across applications, APIs, or approval steps, choose a workflow agent. + +For mixed jobs, assign each tool the part it handles directly. A coding agent can modify a service and open a pull request, while a workflow agent can route that pull request, request approval, update a ticket, and notify an operator. Evaluate how the tools exchange work and report failures, including the permissions and interfaces required for the handoff. When coding and workflow agents handle adjacent stages of one process, evaluate their permissions, handoff interfaces, status reporting, and failure handling rather than treating them as substitutes. diff --git a/apps/sim/content/library/top-ai-assistants-2026/index.mdx b/apps/sim/content/library/top-ai-assistants-2026/index.mdx new file mode 100644 index 00000000000..945a1150df5 --- /dev/null +++ b/apps/sim/content/library/top-ai-assistants-2026/index.mdx @@ -0,0 +1,260 @@ +--- +slug: top-ai-assistants-2026 +title: 'Top AI Assistants in 2026' +description: 'Compare the top AI assistants in 2026 for personal productivity, research, writing, Microsoft 365, and autonomous work, including capabilities, pricing, privacy, and tradeoffs.' +date: 2026-09-10 +updated: 2026-09-10 +authors: + - andrew +readingTime: 12 +tags: [AI Assistants, AI Agents, Productivity, Sim] +ogImage: /library/top-ai-assistants-2026/cover.jpg +canonical: https://www.sim.ai/library/top-ai-assistants-2026 +draft: false +faq: + - q: "What is the difference between an AI chat app and a personal AI assistant?" + a: "An AI chat app (such as ChatGPT or Claude) is primarily a hosted conversational interface that generates responses when prompted. Some chat apps now support scheduled tasks or connector-based actions, but those capabilities are limited to supported services and differ from unrestricted local execution. A personal AI assistant focuses on continuous context and ongoing operational work, which can include managing calendars, triaging email, monitoring web feeds, and executing scheduled tasks." + - q: "Which privacy features should I look for in an AI assistant?" + a: "If data privacy is a priority, review where workspace data, credentials, and model inference are processed. Useful safeguards include local-first data custody, an isolated credential vault, clear retention controls, and explicit model-routing policies. Self-hosted Vellum can keep workspace data on the local machine, but its documented default inference path still uses a cloud AI model, so local hosting alone does not guarantee zero data leakage." + - q: "Can an AI assistant execute actions on my computer safely?" + a: "Safe personal AI assistants use deterministic permission tiers and human-in-the-loop approval gates. While an assistant can autonomously handle low-risk background tasks (such as compiling web research or monitoring feeds), any state-changing or sensitive action—such as sending an external email, modifying files, executing terminal scripts, or initiating payments—requires your direct confirmation before execution." + - q: "How does continuous memory work in modern AI assistants?" + a: "Continuous memory allows an assistant to retain user preferences, active projects, context, and operational habits over months of work rather than resetting per thread. While conventional chat apps offer limited session memory within a single browser, advanced personal assistants unify your memory layer across Mac, mobile, web, and team chat. When you update a task on mobile or mention a preference via voice, your desktop assistant immediately carries that context into its next background run." + - q: "Can an AI assistant run on my local machine and work offline?" + a: "Only if the platform and deployment explicitly support local inference. Vellum's documented local hosting keeps workspace data on the machine but still sends inference through a cloud AI model, so that setup should not be described as fully offline. Truly offline operation requires a local model runtime and features that do not depend on network services." + - q: "Is a free AI assistant good enough for personal and work use?" + a: "A free tier is generally sufficient for occasional writing, web search, summarization, and brainstorming. However, free plans for conversational apps typically restrict context windows, rate-limit access to top-tier reasoning models, and lack background execution. If you rely on an assistant to manage day-to-day operations, coordinate calendars, and run continuous workflows, start with a free plan to evaluate the workflow fit, then upgrade compute and storage as daily task volume expands." +--- + +## TL;DR + +- **[Vellum](https://www.vellum.ai/) | Best overall personal AI assistant.** An open-source personal AI assistant that can run on your computer, remember across conversations, and take actions across connected tools. +- **[ChatGPT](https://openai.com/chatgpt/overview/) | Best for general-purpose use.** Its multimodal tools and broad feature ecosystem support a wide range of everyday conversational tasks. +- **[Claude](https://www.anthropic.com/pricing) | Best for writing and long documents.** It handles extended context, complex analysis, and nuanced prose exceptionally well. +- **[Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity) | Best for research.** It combines web search with concise, cited answers for quick verification. +- **[Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Best for Microsoft 365 users.** It works inside familiar documents, spreadsheets, email, and meeting tools. +- **[Lindy](https://docs.lindy.ai/fundamentals/lindy-101/create-agent) | Best for lightweight autonomous actions.** It handles routine administrative tasks like calendar coordination and inbox triage without requiring a full automation platform. + +Use the detailed entries to compare each assistant's capabilities, pricing, and tradeoffs. If you are deciding between conversational and action-taking software, start with our guide to [AI agents versus chatbots](https://www.sim.ai/library/ai-agent-vs-chatbot). + +## Why AI assistants matter more in 2026 and how we evaluated them + +The right AI assistant depends on the job it performs. While early tools operated primarily as disposable chat boxes in a browser tab, the category in 2026 divides into two distinct experiences: reactive chat windows that generate text on command, and autonomous personal assistants that retain continuous memory and execute actions across everyday work tools. This guide evaluates both approaches across their primary strengths rather than treating them as interchangeable. + +This list covers assistants built primarily for individual users, operators, and developers. Some entries can execute system actions or background routines, while others focus on direct conversational interaction. Heavy workflow automation platforms like Zapier and Make fall outside our scope because they focus on connecting backend APIs rather than serving as a continuous personal assistant. Readers comparing that category can review the [best AI automation tools in 2026](https://www.sim.ai/library/best-ai-automation-tools-2026). + +We evaluated each assistant across model flexibility, integration depth, privacy controls, continuous memory, multimodal capabilities, and pricing transparency. Each ranking also accounts for concrete tradeoffs, such as closed ecosystems, cloud data retention, or machine connectivity requirements. Rankings reflect each product's fit for its target use case rather than a single aggregate score. + +## What to look for in a personal AI assistant + +- **Model quality and access.** Check which models the assistant offers and whether you can select or switch models per task. The best platforms let you route strategy to frontier reasoning models while using fast or local models for routine data processing. Our [BYOK and multi-model guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) explains this approach in more detail. +- **Integration breadth.** Choose an assistant that connects directly to the tools where you already work. Useful integrations can include email, calendars, local files, messaging clients, and code repositories. +- **Privacy and data handling.** Review whether the provider stores prompts, uses conversation logs for model training, or isolates credentials. Users handling personal or business data should prioritize clear storage and credential policies. +- **Multimodal capability.** Confirm which formats the assistant can process and produce. Image interpretation, voice conversation, document parsing, and screen analysis can reduce manual context entry. +- **Pricing transparency.** Compare monthly base fees alongside compute limits, model access restrictions, and extra charges. A clear tier structure provides better long-term predictability than opaque credit burn rates. + +## Vellum + +**Best for** + +People who want an autonomous personal AI assistant that can run on their computer, take actions across work tools, and keep data under their control. + +**What it is** + +[Vellum describes itself as a personal AI assistant](https://www.vellum.ai/) that remembers how you work and takes action across connected tools. Unlike traditional chat interfaces that wait passively in a browser tab, Vellum can work in the background through routines: managing schedules, handling connected email tasks, monitoring information, executing scheduled tasks, and building lightweight internal tools. + +Vellum has [persistent memory that carries useful knowledge between conversations](https://www.vellum.ai/docs/key-concepts/memory-and-context). Its supported surfaces include web, macOS, iOS, Slack, Telegram, and terminal. Hosting determines the privacy boundary: [cloud deployments store data in a private encrypted cloud account, while self-hosted Mac deployments keep workspace data on the machine](https://www.vellum.ai/docs/trust-security/privacy-and-data). + +Vellum supports managed cloud and self-hosted deployments. Its documentation says that [the default managed model uses Anthropic Claude and local hosting still sends inference through a cloud AI model](https://www.vellum.ai/docs/trust-security/privacy-and-data), so local hosting should not be confused with fully offline inference. + +**Pros** + +- [Persistent memory and access across multiple supported surfaces](https://www.vellum.ai/) provide continuity between conversations. +- [Background routines can handle scheduled checks and recurring work](https://www.vellum.ai/). +- [OS-level sandboxing, a keychain-backed credential vault, and scoped trust rules](https://www.vellum.ai/docs/developer-guide/security) provide explicit security boundaries. +- Managed and self-hosted deployment options support different operating preferences. +- [The open-source, self-hostable option allows inspection and control of the deployment](https://www.vellum.ai/pricing). + +**Cons** + +- [Local actions require the host computer to remain awake and available](https://www.vellum.ai/docs/hosting-options/local-hosting). +- Cloud hosting is more convenient but does not offer the same data-locality profile as self-hosting. +- It is not designed as a disposable web-search widget; it targets users wanting a persistent operational assistant. + +**Pricing** + +[Vellum's pricing page](https://www.vellum.ai/pricing) lists a free starting option and three packaged paid plans: Mighty at $30/month, Super at $100/month, and Ultra at $200/month. It also says self-hosting has no platform fee; model and infrastructure costs can still apply. + +## ChatGPT + +**Best for** + +People who want one general-purpose assistant for everyday writing, research, coding, file analysis, image work, and voice conversations. + +**What it is** + +[ChatGPT is OpenAI's general-purpose AI assistant](https://openai.com/chatgpt/pricing/). Depending on plan, it supports messages, file uploads, image generation, deep research, memory, projects, scheduled tasks, and custom GPTs. Custom GPTs let users adapt ChatGPT for specialized tasks without building an assistant from scratch. + +ChatGPT is centered on a hosted assistant experience, but it is no longer limited to purely reactive chat: [eligible accounts can run one-time or recurring scheduled tasks and monitor for supported changes](https://help.openai.com/en/articles/10291617-scheduled-tasks-in-chatgpt). Available models, tools, usage limits, and data controls vary by plan. + +**Pros** + +- Its [plan matrix covers a broad range of multimodal and productivity features](https://openai.com/chatgpt/pricing/). +- [Voice, file, research, and image tools support varied interactive work](https://openai.com/chatgpt/overview/). +- [Projects and custom GPTs provide reusable context and task-specific behavior](https://openai.com/chatgpt/pricing/). +- The product spans everyday chat, creative work, research, and coding use cases. + +**Cons** + +- Scheduled tasks do not equate to unrestricted local operating-system execution. +- The hosted product is tied to OpenAI's model and service ecosystem. +- Consumer users should review their settings: [OpenAI's Data Controls let signed-in users turn off “Improve the model for everyone”](https://help.openai.com/en/articles/7730893-data-controls). + +**Pricing** + +OpenAI offers free and paid plans. [ChatGPT Plus costs $20/month](https://help.openai.com/en/articles/6950777-what-is-chatgpt-plus), while other individual, business, and enterprise options provide different tools, limits, and administration features. + +## Claude + +**Best for** + +Knowledge workers requiring long-document analysis, complex reasoning, and nuanced writing. + +**What it is** + +[Claude is Anthropic's general-purpose AI assistant](https://www.anthropic.com/pricing). It can analyze documents, compare source materials, write prose, interpret images, generate code, and visualize data. [Claude Projects organize chats and reference material in dedicated workspaces](https://www.anthropic.com/news/projects), making the product well suited to tasks that synthesize information across reports or codebases. + +Claude often produces measured answers with thoughtful caveats, making it useful for research synthesis and sensitive editorial tasks. It is not limited to an isolated web app: [connectors can retrieve data and take actions in connected services while inheriting the user's permissions](https://support.anthropic.com/en/articles/11176164-pre-built-web-connectors-using-remote-mcp). These connectors are distinct from unrestricted local computer control. + +**Pros** + +- Strong comprehension across long documents and codebases. +- Articulate prose for drafting and revision. +- [Projects provide structured workspaces with persistent reference documents](https://www.anthropic.com/news/projects). +- [Connectors extend Claude into supported apps and services](https://support.anthropic.com/en/articles/11176164-pre-built-web-connectors-using-remote-mcp). + +**Cons** + +- Connector availability and behavior depend on the service, client, and configuration. +- Safety controls can occasionally block a legitimate research query. +- The hosted assistant primarily uses Anthropic's Claude model family. + +**Pricing** + +[Anthropic offers a free Claude plan and prices Claude Pro at $20 when billed monthly or $200 when billed annually](https://www.anthropic.com/pricing). Team and enterprise options have separate current terms. + +## Perplexity + +**Best for** + +Researchers who want web information with visible source citations. + +**What it is** + +[Perplexity Pro is designed around search, citations, file uploads, research, and model choice](https://www.perplexity.ai/help-center/en/articles/10352901-what-is-perplexity-pro). Perplexity searches the web, summarizes relevant material, and attaches citations to underlying pages. Follow-up queries preserve research context, helping users narrow a topic without rebuilding search terms from scratch. + +Perplexity is useful for market scans, product comparisons, and early-stage research. [Its sessions retain sources and context across follow-up questions](https://www.perplexity.ai/help-center/en/articles/10354769-what-is-a-thread). Its core strength is information retrieval rather than local operational control: it is not primarily designed to organize a desktop inbox or execute arbitrary system tasks. + +**Pros** + +- [Searches web content and provides links to supporting sources](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity). +- [Conversational queries let users refine a topic while retaining context](https://www.perplexity.ai/help-center/en/articles/10354775-technical-capabilities-of-threads). +- [Pro subscribers can switch among supported advanced models](https://www.perplexity.ai/help-center/en/articles/10354919-what-advanced-ai-models-are-included-in-my-subscription). +- [Research and file tools support deeper source exploration](https://www.perplexity.ai/help-center/en/articles/10352901-what-is-perplexity-pro). + +**Cons** + +- Research is its main focus rather than local action execution. +- Citations can support only part of a generated claim, so important details still require manual verification. +- Its general creative and productivity experience differs from broader assistants. + +**Pricing** + +Perplexity offers free and paid plans with different access to searches, citations, uploads, research, and models. Review [Perplexity's current subscription comparison](https://www.perplexity.ai/help-center/en/articles/11187416-which-perplexity-subscription-plan-is-right-for-you) before choosing a plan, because limits and included features can change. + +## Microsoft Copilot + +**Best for** + +Microsoft 365 users who want AI assistance embedded inside documents, email, spreadsheets, and meetings. + +**What it is** + +[Microsoft 365 Copilot works across Word, Excel, PowerPoint, Outlook, and Teams](https://learn.microsoft.com/en-us/office365/servicedescriptions/office-365-platform-service-description/microsoft-365-copilot). It can assist with drafting and summarizing documents, analyzing and visualizing spreadsheet data, creating presentations, working with email, and supporting meetings. + +Copilot combines language models with Microsoft Graph and Microsoft 365 services, using work content that the user has permission to access. Its deepest value therefore comes from the Microsoft ecosystem, while custom local scripts and independent model choice are not its main focus. + +**Pros** + +- [Works directly inside familiar Microsoft 365 applications](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work). +- [Microsoft Graph integration grounds responses in work content users have permission to access](https://learn.microsoft.com/en-us/microsoft-365/copilot/microsoft-365-copilot-overview). +- [Business offerings include organizational security and administrative controls](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing). + +**Cons** + +- It delivers less value when a workflow sits outside Microsoft 365. +- Feature availability depends on the underlying subscription and whether an administrator assigns a Copilot add-on license. +- Business deployment can require administrative configuration. + +**Pricing** + +Microsoft offers several individual and organizational Copilot packages rather than one universal price. Review the [current Microsoft 365 Copilot plans and pricing](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) alongside the licensing prerequisites for the capabilities you need. + +## Lindy + +**Best for** + +Users who want an AI personal assistant to handle recurring email, calendar, and meeting triage. + +**What it is** + +[Lindy lets users configure assistants that connect to work apps](https://docs.lindy.ai/integrations/overview). Users can describe work in natural language and configure triggers and approval steps for tasks such as meeting follow-up, drafting email replies, or scheduling calendar events. + +Lindy suits operators who want more action-taking than a standard chat window provides without building a complex workflow from scratch. It is a closed, hosted product organized around connected applications rather than a self-hosted local assistant. + +**Pros** + +- [Its meeting tools can schedule meetings, take notes, and handle follow-up tasks](https://www.lindy.ai/solutions/meetings). +- [Templates provide pre-built workflows for common administrative tasks](https://docs.lindy.ai/fundamentals/lindy-101/templates). +- [Human-in-the-loop controls can pause side-effecting actions for confirmation](https://docs.lindy.ai/testing/human-in-the-loop). + +**Cons** + +- As a hosted service, it has a different data-control profile from a local deployment. +- Usage allowances vary by plan, so users should compare expected volume with current terms. +- Local operating-system execution and self-hosted model support are not its main design focus. + +**Pricing** + +[Lindy's current pricing page](https://www.lindy.ai/pricing) lists a seven-day trial rather than a permanent free tier. Individual paid plans start with Plus at $49.99/month and scale to higher-capacity plans. + +## How the top AI assistants compare + +The table summarizes product support for model access, integrations, privacy controls, multimodal inputs, and pricing clarity. The ratings reflect the capabilities described in this article rather than a controlled benchmark. ✅ indicates strong support, 🟡 indicates limited or plan-dependent support, and ❌ indicates that a criterion falls outside the product's main focus. + +| Rank | Name | Best for | Standout capability | Tradeoff | Models | Integrations | Privacy | Multimodal | Pricing | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | [Vellum](https://www.vellum.ai/) | Autonomous personal work and operations | Persistent memory and background actions | Local actions require an available host | ✅ | ✅ | ✅ | 🟡 | ✅ | +| 2 | [ChatGPT](https://openai.com/chatgpt/overview/) | General-purpose personal use | Broad multimodal tools and custom GPTs | Hosted ecosystem; local action limits | ✅ | ✅ | 🟡 | ✅ | ✅ | +| 3 | [Claude](https://www.anthropic.com/pricing) | Writing and long documents | Deep long-context comprehension | Connector capabilities vary | ✅ | 🟡 | 🟡 | ✅ | ✅ | +| 4 | [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity) | Web research | Search answers with citations | Local action execution is not its focus | ✅ | 🟡 | 🟡 | ✅ | ✅ | +| 5 | [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Microsoft 365 users | Embedded inside Microsoft office apps | Best features depend on licenses | 🟡 | ✅ | ✅ | ✅ | 🟡 | +| 6 | [Lindy](https://docs.lindy.ai/fundamentals/lindy-101/create-agent) | Lightweight administrative tasks | Trigger-based email and calendar actions | Hosted, plan-based service | 🟡 | ✅ | 🟡 | 🟡 | 🟡 | + +## Which AI assistant fits you + +- **Operators who need an assistant that takes real actions should pick [Vellum](https://www.vellum.ai/).** Its local machine execution, background scheduling, and continuous memory target operational workloads rather than only generating text. +- **Researchers should pick [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity).** Its search-first interface provides answers with citations you can inspect immediately. +- **Microsoft 365 users should pick [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work).** It works inside tools like Word, Outlook, and Teams while drawing on permitted work context. +- **Users seeking lightweight administrative automations should pick [Lindy](https://docs.lindy.ai/fundamentals/lindy-101/create-agent).** It handles routine inbox and calendar actions without requiring a complex workflow automation builder. For a broader comparison, see the [best AI agents for executive assistant tasks](https://www.sim.ai/library/best-ai-agents-for-executive-assistant-tasks). +- **People who want a versatile, ready-made chat app should consider [ChatGPT](https://openai.com/chatgpt/overview/).** It combines writing, coding, file analysis, image tools, and voice features in one hosted product. +- **People who work with long documents should consider [Claude](https://www.anthropic.com/news/projects).** Its context handling and writing style suit report analysis, technical review, and prose editing. +- **Teams prioritizing data sovereignty and local execution should consider a [self-hosted Vellum deployment](https://www.vellum.ai/docs/hosting-options/local-hosting).** Its open-source architecture and local hosting option provide more infrastructure control. + +Calendar-heavy users can also compare purpose-built options in our guide to the [best AI agents for scheduling and calendar management](https://www.sim.ai/library/best-ai-agents-for-scheduling-and-calendar-management-in-2026). + +## Why Vellum leads this list + +Vellum leads because it bridges the gap between conversational chat and operational execution. While traditional assistants center on a hosted prompt-and-response experience, Vellum can function as a persistent personal assistant, remember useful context across sessions, and execute background routines. + +Its open-source foundation, hosting choices, and security controls distinguish it from assistants that bind every task to one hosted interface. Self-hosting provides the strongest local control, while cloud hosting trades some data locality for convenience. That combination gives users a practical choice between managed operation and a more private, hands-on deployment. diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.tsx index 8bac323aff8..5648b046e6b 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-people.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-people.tsx @@ -16,8 +16,8 @@ import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { EnrollmentConnections } from '@/ee/credential-groups/components/credential-group-enrollment-connections' import { OrganizationAccountInviteModal } from '@/ee/credential-groups/components/organization-account-invite-modal' +import { OrganizationPersonConnections } from '@/ee/credential-groups/components/organization-person-connections' import { useOrganizationAccountPeople, useResendOrganizationAccountInvitation, @@ -37,6 +37,8 @@ interface OrganizationAccountPeopleProps { actions?: SettingsAction[] } enabled?: boolean + requestDisabled?: boolean + filters?: ReactNode setupFallback?: ReactNode } export function OrganizationAccountPeople({ @@ -45,6 +47,8 @@ export function OrganizationAccountPeople({ panel, enabled = true, setupFallback, + filters, + requestDisabled = false, }: OrganizationAccountPeopleProps) { const resend = useResendOrganizationAccountInvitation() const revoke = useRevokeOrganizationAccountEnrollment() @@ -69,12 +73,13 @@ export function OrganizationAccountPeople({ text: 'Request connections', icon: Plus, variant: 'primary', - disabled: pending || awaitingSetup, + disabled: pending || awaitingSetup || requestDisabled, onSelect: () => setInviteOpen(true), }, ...(panel?.actions ?? []), ]} > + {filters} {awaitingSetup ? ( setupFallback ) : ( @@ -110,38 +115,14 @@ export function OrganizationAccountPeople({ icon={} iconVariant='custom' title={person.email} - description={ - searchConnection && person.status === 'revoked' ? ( - 'Access revoked' - ) : searchConnection && - !person.connections.some( - (connection) => connection.status === 'active' - ) ? ( - person.connections.some( - (connection) => connection.status === 'needs_reauth' - ) ? ( - 'Reconnect required' - ) : person.connections.some( - (connection) => connection.status === 'revoked' - ) ? ( - 'Disconnected' - ) : ( - 'Not connected' - ) - ) : ( - - ) - } + description={} trailing={ resend.mutate( { diff --git a/apps/sim/ee/credential-groups/components/organization-person-connections.tsx b/apps/sim/ee/credential-groups/components/organization-person-connections.tsx new file mode 100644 index 00000000000..1654bbf830a --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-person-connections.tsx @@ -0,0 +1,86 @@ +import { ChipTag } from '@sim/emcn' +import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' + +interface OrganizationPersonConnectionsProps { + person: CredentialGroupEnrollmentDetail +} + +const CONNECTION_STATUS_LABELS = { + active: 'Connected', + needs_reauth: 'Reconnect required', + revoked: 'Disconnected', +} as const + +export function OrganizationPersonConnections({ person }: OrganizationPersonConnectionsProps) { + if (person.status === 'revoked') return <>Access revoked + + const connections = person.connections.map((connection) => { + const display = + connection.provider === 'gitlab' + ? resolveCredentialDisplay({ + type: 'personal_token', + providerId: 'gitlab', + displayName: 'GitLab', + }) + : undefined + const service = + connection.provider !== 'gitlab' + ? getCredentialGroupProviderService(connection.provider) + : undefined + return { + key: `${connection.provider}:${connection.status}`, + name: service?.name ?? display?.detailTitle ?? 'GitLab', + icon: service?.icon ?? display?.icon, + status: connection.status, + count: connection.count, + } + }) + const total = + connections.reduce( + (count, connection) => count + (connection.status === 'active' ? connection.count : 0), + 0 + ) + person.mcpConnections.filter((connection) => connection.status === 'active').length + + const statuses = [...connections, ...person.mcpConnections].map(({ status }) => status) + + return ( + + + {total > 0 + ? `${total} ${total === 1 ? 'account' : 'accounts'} connected` + : statuses.includes('needs_reauth') + ? 'Reconnect required' + : statuses.includes('revoked') + ? 'Disconnected' + : person.status === 'delivery_failed' + ? 'Connection request failed' + : person.expired + ? 'Connection request expired' + : 'Not connected'} + + {(connections.length > 0 || person.mcpConnections.length > 0) && ( + + {connections.map(({ key, name, icon: Icon, status, count }) => ( + + {Icon && } + {name} + {count > 1 ? ` (${count})` : ''} + {status !== 'active' && ` · ${CONNECTION_STATUS_LABELS[status]}`} + + ))} + {person.mcpConnections.map((connection) => ( + + {connection.name} · {CONNECTION_STATUS_LABELS[connection.status]} + + ))} + + )} + + ) +} diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx index 2314c7d445c..1b07c233ca3 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ refetchApps: vi.fn(), manifest: vi.fn(), install: vi.fn(), + accounts: vi.fn(), + refetchAccounts: vi.fn(), })) vi.mock('@/hooks/queries/credential-groups', () => ({ useStartSlackCredentialGroupConfiguration: () => ({ @@ -34,6 +36,11 @@ vi.mock('@/hooks/queries/slack-search', () => ({ useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }), })) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + organizationAccountsKeys: { detail: (id: string) => ['organization-accounts', id] }, + useOrganizationAccounts: mocks.accounts, +})) + import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' import { SLACK_MANAGED_USER_SCOPES, @@ -69,6 +76,25 @@ describe('Slack member access selection', () => { vi.spyOn(toast, 'success').mockReturnValue('toast') vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.create.mockResolvedValue(undefined) + mocks.accounts.mockReturnValue({ + isSuccess: true, + isPending: false, + isFetching: false, + data: { + credentialGroup: { + id: 'group-1', + options: [ + { + provider: 'slack', + status: 'active', + configurationStatus: 'ready', + }, + ], + }, + }, + error: null, + refetch: mocks.refetchAccounts, + }) mocks.apps.mockReturnValue({ isSuccess: true, isPending: false, @@ -172,9 +198,11 @@ describe('Slack member access selection', () => { function appSetupDialog(organization = false) { return Array.from(document.querySelectorAll('[role="dialog"]')).find((dialog) => - dialog.textContent?.includes( - organization ? 'Set up Sim Search in Slack' : 'Create a custom Slack bot' - ) + organization + ? ['Create Slack app', 'Update Slack app'].includes( + dialog.querySelector('h2')?.textContent ?? '' + ) + : dialog.textContent?.includes('Create a custom Slack bot') ) } @@ -349,9 +377,9 @@ describe('Slack member access selection', () => { await clickButton('Install Sim Search') const dialog = appSetupDialog(true) expect(dialog).toBeDefined() - expect(dialog?.textContent).toContain('Step 1 of 3') + expect(dialog?.textContent).toContain('Create Slack app') expect(dialog?.textContent).not.toContain('App manifest') - expect(dialog?.textContent).toContain('Create app in Slack') + expect(dialog?.querySelector('a')?.textContent).toBe('Create app') expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search') expect(mocks.start).not.toHaveBeenCalled() expect(mocks.create).not.toHaveBeenCalled() @@ -456,6 +484,7 @@ describe('Slack member access selection', () => { { id: 'installation-1', appId: 'A_APP', + appKind: 'custom', teamId: 'T_TEAM', teamName: 'sim', credentialId: bot.id, @@ -472,7 +501,7 @@ describe('Slack member access selection', () => { expect(document.body.textContent).not.toContain('Install Sim Search first') await clickButton('Manage Sim Search app') const dialog = appSetupDialog(true) - expect(dialog?.textContent).toContain('Reconnect Slack Search') + expect(dialog?.textContent).toContain('Update Slack app') expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Search bot') await clickButton('Close', dialog) await clickButton('Verify and add') @@ -487,6 +516,166 @@ describe('Slack member access selection', () => { }) }) + it.each([false, true])( + 'skips completed shared app setup entirely (refreshed installation: %s)', + async (refresh) => { + const installation = { + id: 'installation-1', + appId: 'A_SHARED', + appKind: 'shared', + teamId: 'T_TEAM', + teamName: 'sim', + credentialId: bot.id, + enabled: true, + needsValidation: false, + } + if (refresh) { + await render(undefined, [], 'org-1') + expect(document.body.textContent).toContain('Install Sim Search first') + } + mocks.apps.mockReturnValue({ + isSuccess: true, + isPending: false, + data: { installations: [installation], bots: [bot], sharedAppAvailable: true }, + error: null, + }) + await render(undefined, [], 'org-1') + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.onOpenChange).toHaveBeenCalledExactlyOnceWith(false) + expect(mocks.start).not.toHaveBeenCalled() + expect(mocks.install).not.toHaveBeenCalled() + expect(window.open).not.toHaveBeenCalled() + } + ) + + it.each([ + { enabled: false, needsValidation: false, sharedAppAvailable: true }, + { enabled: true, needsValidation: true, sharedAppAvailable: true }, + { enabled: true, needsValidation: false, sharedAppAvailable: false }, + ])('keeps incomplete shared app setup actionable: %j', async (status) => { + const accounts = mocks.accounts() + accounts.data.credentialGroup.options[0].configurationStatus = 'needs_update' + mocks.apps.mockReturnValue({ + isSuccess: true, + isPending: false, + data: { + installations: [ + { + id: 'installation-1', + appId: 'A_SHARED', + appKind: 'shared', + teamId: 'T_TEAM', + teamName: 'sim', + credentialId: bot.id, + enabled: status.enabled, + needsValidation: status.needsValidation, + }, + ], + bots: [bot], + sharedAppAvailable: status.sharedAppAvailable, + }, + error: null, + }) + await render(undefined, [], 'org-1') + expect(document.body.textContent).toContain('Manage Sim Search app') + expect(document.body.textContent).not.toContain('Verify and add') + expect(document.body.textContent).not.toContain('Update member access') + expect(mocks.onOpenChange).not.toHaveBeenCalled() + expect(mocks.start).not.toHaveBeenCalled() + }) + + it.each(['removed', 'needs_update', 'needs_update_failed', 'pending', 'error', 'refreshing'])( + 'does not skip shared setup when member configuration is %s', + async (state) => { + mocks.apps.mockReturnValue({ + isSuccess: true, + isPending: false, + data: { + installations: [ + { + id: 'installation-1', + appId: 'A_SHARED', + appKind: 'shared', + teamId: 'T_TEAM', + teamName: 'sim', + credentialId: bot.id, + enabled: true, + needsValidation: false, + }, + ], + bots: [bot], + sharedAppAvailable: true, + }, + error: null, + }) + const current = mocks.accounts() + mocks.accounts.mockReturnValue({ + ...current, + isSuccess: !['pending', 'error'].includes(state), + isPending: state === 'pending', + isFetching: state === 'refreshing', + error: state === 'error' ? new Error('Could not load member setup') : null, + data: + state === 'pending' + ? undefined + : { + credentialGroup: { + id: 'group-1', + options: + state === 'removed' + ? [] + : [ + { + provider: 'slack', + status: 'active', + configurationStatus: 'needs_update', + }, + ], + }, + }, + }) + await render(undefined, [], 'org-1') + expect(mocks.onOpenChange).not.toHaveBeenCalled() + expect(mocks.start).not.toHaveBeenCalled() + if (state === 'error') { + expect(document.body.textContent).toContain('Could not load member setup') + expect(document.body.textContent).not.toContain('Update member access') + await clickButton('Retry') + expect(mocks.refetchAccounts).toHaveBeenCalledOnce() + } else if (state === 'pending' || state === 'refreshing') { + expect(document.body.textContent).toContain('Checking the installed Slack app') + expect(document.body.textContent).not.toContain('Update member access') + } else if (state === 'needs_update' || state === 'needs_update_failed') { + expect(document.body.textContent).toContain('Member access is outdated') + if (state === 'needs_update_failed') + mocks.start.mockRejectedValueOnce(new Error('Try again')) + await clickButton('Update member access') + expect(mocks.start).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + credentialGroupId: 'group-1', + body: { + appId: 'A_SHARED', + teamId: 'T_TEAM', + requiredScopes: [...SLACK_SEARCH_USER_SCOPES], + }, + }) + expect(mocks.install).not.toHaveBeenCalled() + if (state === 'needs_update_failed') { + expect(toast.error).toHaveBeenCalledWith('Try again') + expect(popup.close).toHaveBeenCalledOnce() + expect(mocks.onOpenChange).not.toHaveBeenCalled() + await clickButton('Update member access') + } + await completeAuthorization() + expect(toast.success).toHaveBeenCalledWith('Slack configured') + expect(mocks.onOpenChange).toHaveBeenCalledWith(false) + } else { + expect(document.body.textContent).toContain('Manage Sim Search app') + expect(document.body.textContent).not.toContain('Update member access') + } + } + ) + it('only changes existing workflow access after the user selects Search documents', async () => { await render(SLACK_MANAGED_USER_SCOPES) const access = Array.from(document.querySelectorAll('button')).find((node) => 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 aed7460ef51..aee93dc4b36 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 @@ -26,7 +26,10 @@ import { } from '@/lib/credential-groups/slack-managed-user-scopes' import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { + organizationAccountsKeys, + useOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' import { useSlackSearchInstallations } from '@/hooks/queries/slack-search' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' @@ -101,6 +104,28 @@ export function SlackManagedUsersModal({ const selectedApp = availableApps.find((app) => app.appId === appId) ?? (availableApps.length === 1 && !appId ? availableApps[0] : undefined) + const sharedAppInstalled = organizationSetup && selectedApp?.appKind === 'shared' + const accounts = useOrganizationAccounts(open && sharedAppInstalled ? organizationId : undefined) + const memberGroup = accounts.data?.credentialGroup + const memberOption = memberGroup?.options.find( + (option) => option.provider === 'slack' && option.status === 'active' + ) + const sharedAppCanAuthorize = Boolean( + sharedAppInstalled && + apps.isSuccess && + !apps.isFetching && + !apps.error && + apps.data?.sharedAppAvailable && + selectedApp.enabled && + !selectedApp.needsValidation && + accounts.isSuccess && + !accounts.isFetching && + !accounts.error && + memberGroup?.id === credentialGroupId + ) + const sharedAppReady = sharedAppCanAuthorize && memberOption?.configurationStatus === 'ready' + const sharedAppNeedsUpdate = + sharedAppCanAuthorize && memberOption?.configurationStatus === 'needs_update' const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') const [pending, setPending] = useState(false) @@ -201,9 +226,8 @@ export function SlackManagedUsersModal({ } /** - * The subscription's identity is `open` alone. Routing the handler through a - * ref keeps a `bots` refetch from closing and reopening the channel mid-flow, - * which would drop an already-queued authorization message from the popup. + * Routing the handler through a ref keeps a bots refetch from reopening the + * channel mid-flow and dropping an already-queued authorization message. */ const messageHandler = useRef(handleAuthorizationMessage) useEffect(() => { @@ -211,14 +235,18 @@ export function SlackManagedUsersModal({ }) useEffect(() => { - if (!open) return + if (!open || sharedAppReady) return const channel = new BroadcastChannel(CHANNEL_NAME) channel.onmessage = (event: MessageEvent) => { if (!isSlackManagedUsersMessage(event.data)) return messageHandler.current(event.data) } return () => channel.close() - }, [open]) + }, [open, sharedAppReady]) + + useEffect(() => { + if (open && sharedAppReady && !appSetupOpen) onOpenChange(false) + }, [open, sharedAppReady, appSetupOpen, onOpenChange]) useEffect( () => () => { @@ -246,7 +274,12 @@ export function SlackManagedUsersModal({ } const handleSubmit = async () => { - if (pending || (!organizationSetup && !selectedBot)) return + if ( + pending || + (sharedAppInstalled && !sharedAppNeedsUpdate) || + (!organizationSetup && !selectedBot) + ) + return if ( organizationSetup ? !selectedApp || !requiredScopes.length @@ -300,14 +333,22 @@ export function SlackManagedUsersModal({ } } + if (sharedAppReady && !appSetupOpen) return null + const noBots = !organizationSetup && !isLoading && bots.length === 0 const needsApp = organizationSetup && apps.isSuccess && availableApps.length === 0 - const title = organizationSetup ? 'Connect Slack accounts' : 'Set up Slack' + const checkingSetup = + apps.isPending || + (sharedAppInstalled && (apps.isFetching || accounts.isPending || accounts.isFetching)) + const failedSetup = apps.error ? apps : sharedAppInstalled && accounts.error ? accounts : null + const title = organizationSetup ? 'Set up Slack app' : 'Set up Slack' const primaryLabel = isLoading ? 'Loading...' : pending ? 'Waiting for Slack...' - : 'Verify and add' + : sharedAppNeedsUpdate + ? 'Update member access' + : 'Verify and add' const primaryDisabled = isLoading || noBots || @@ -330,15 +371,19 @@ export function SlackManagedUsersModal({ {organizationSetup ? ( - apps.isPending ? ( + checkingSetup ? (

Checking the installed Slack app…

- ) : apps.error ? ( - - void apps.refetch()} disabled={apps.isFetching}> + ) : failedSetup ? ( + + void failedSetup.refetch()} disabled={failedSetup.isFetching}> Retry @@ -375,8 +420,11 @@ export function SlackManagedUsersModal({ )}

- Verify member authorization for the installed app. Each member can then connect - their Slack account to index channels and DMs they can access. + {sharedAppInstalled + ? sharedAppNeedsUpdate + ? 'Member access is outdated. Update it so members can reconnect their Slack accounts.' + : 'The Sim Search installation needs attention. Manage the app to finish setup.' + : 'Verify member authorization for the installed app. Each member can then connect their Slack account to index channels and DMs they can access.'}

{selectedApp && ( setAppSetupOpen(true)} disabled={pending}> @@ -473,15 +521,17 @@ export function SlackManagedUsersModal({ onClick: () => setAppSetupOpen(true), }, } - : noBots + : sharedAppInstalled && !sharedAppNeedsUpdate ? { defaultAction: 'dismiss' as const } - : { - primaryAction: { - label: primaryLabel, - onClick: () => void handleSubmit(), - disabled: primaryDisabled, - }, - })} + : noBots + ? { defaultAction: 'dismiss' as const } + : { + primaryAction: { + label: primaryLabel, + onClick: () => void handleSubmit(), + disabled: primaryDisabled, + }, + })} />
{open && diff --git a/apps/sim/ee/organization-search-stats/search-params.ts b/apps/sim/ee/organization-search-stats/search-params.ts new file mode 100644 index 00000000000..0514a128245 --- /dev/null +++ b/apps/sim/ee/organization-search-stats/search-params.ts @@ -0,0 +1,22 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { SEARCH_STATS_PERIODS, SEARCH_STATS_SURFACES } from '@/lib/knowledge/search/stats' + +export const organizationSearchStatsParsers = { + period: parseAsStringLiteral(SEARCH_STATS_PERIODS).withDefault('30d'), + /** An absent surface includes every Search entry point. */ + surface: parseAsStringLiteral(SEARCH_STATS_SURFACES), + startDate: parseAsString, + endDate: parseAsString, +} + +export const organizationSearchStatsUrlOptions = { + history: 'replace', + shallow: true, + clearOnDefault: true, + urlKeys: { + period: 'stats-period', + surface: 'stats-surface', + startDate: 'stats-start', + endDate: 'stats-end', + }, +} as const diff --git a/apps/sim/ee/workspace-forking/lib/remap/block-identity.ts b/apps/sim/ee/workspace-forking/lib/remap/block-identity.ts index 6360584cf69..1b0a94d540a 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/block-identity.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/block-identity.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { uuidV5 } from '@/lib/core/utils/uuid-v5' /** * Fixed namespace UUID for fork block-identity derivation. Changing this value @@ -8,30 +8,6 @@ import { createHash } from 'node:crypto' */ const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13' -function uuidToBytes(uuid: string): Buffer { - return Buffer.from(uuid.replace(/-/g, ''), 'hex') -} - -/** - * Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs - * always yield the same UUID, which is how fork block identity stays stable. - * - * SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation, - * never for secrecy or integrity — not a security use of the algorithm. Swapping it would change - * every derived id, breaking webhook URLs and stored block-id references across existing forks - * (see {@link FORK_BLOCK_NAMESPACE}). - */ -function uuidV5(name: string, namespace: string): string { - const hash = createHash('sha1') - hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm] - hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm] - const bytes = hash.digest().subarray(0, 16) - bytes[6] = (bytes[6] & 0x0f) | 0x50 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - const hex = bytes.toString('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - /** * Derive the target block id for a source block copied into a target workflow. * diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 0eff6c6a6cc..258e79b3bec 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -2,6 +2,7 @@ import '@sim/testing/mocks/executor' import { loggerMock, resetEnvMock, setEnv } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveMothershipConversation } from '@/lib/mothership/conversation-id' import { BlockType } from '@/executor/constants' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' import type { ExecutionContext, StreamingExecution } from '@/executor/types' @@ -792,7 +793,7 @@ describe('MothershipBlockHandler', () => { messages: [{ role: 'user', content: 'Hello from workflow' }], workspaceId: 'workspace-1', userId: 'user-1', - chatId: 'chat-uuid', + chatId: resolveMothershipConversation('workspace-1', 'chat-uuid').chatId, messageId: 'message-uuid', requestId: 'request-uuid', secretScope: 'all', @@ -876,7 +877,7 @@ describe('MothershipBlockHandler', () => { messages: [{ role: 'user', content: 'Continue this thread' }], workspaceId: 'workspace-1', userId: 'user-1', - chatId: 'existing-chat-id', + chatId: resolveMothershipConversation('workspace-1', 'existing-chat-id').chatId, messageId: 'message-uuid', requestId: 'request-uuid', secretScope: 'all', @@ -887,7 +888,7 @@ describe('MothershipBlockHandler', () => { expect(mockGenerateId).toHaveBeenCalledTimes(2) }) - it('keeps a resolved conversation ID out of logs while forwarding it unchanged', async () => { + it('keeps a resolved conversation ID out of logs and off the wire', async () => { const conversationId = 'chat-plaintext-secret-__var_API_KEY-__sim_secret_API_KEY' mockGenerateId.mockReturnValueOnce('message-uuid').mockReturnValueOnce('request-uuid') fetchMock.mockResolvedValue( @@ -907,7 +908,8 @@ describe('MothershipBlockHandler', () => { const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] const body = JSON.parse(String(options.body)) - expect(body.chatId).toBe(conversationId) + expect(body.chatId).toBe(resolveMothershipConversation('workspace-1', conversationId).chatId) + expect(body.chatId).not.toContain('chat-plaintext-secret') const logged = JSON.stringify(mockMothershipLogger.info.mock.calls) expect(logged).not.toContain('chat-plaintext-secret') @@ -936,7 +938,9 @@ describe('MothershipBlockHandler', () => { const result = await handler.execute(context, block, inputs) const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(JSON.parse(String(options.body)).chatId).toBe('x') + expect(JSON.parse(String(options.body)).chatId).toBe( + resolveMothershipConversation('workspace-1', 'x').chatId + ) expect(result).toMatchObject({ conversationId: 'x' }) expect(inputs.conversationId).toBe('x') expect(context.resolvedSecretTraceRegistry?.getActiveMatches()).toEqual([]) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index ba9a5335b2c..f367402ae7d 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -24,6 +24,7 @@ import { import { discoverMcpServerToolsAsExecutor } from '@/lib/internal/mcp/discover-tools' import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { resolveMcpToolBinding } from '@/lib/mcp/tool-binding' +import { resolveMothershipConversation } from '@/lib/mothership/conversation-id' import { areModelSafeWorkspaceFileKeys, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, @@ -485,7 +486,7 @@ function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStream function formatMothershipBlockOutput( result: MothershipExecuteResult, - fallbackChatId: string + conversationId: string ): NormalizedBlockOutput { const formattedList = (result.toolCalls || []).map((tc: Record) => ({ name: typeof tc.name === 'string' ? tc.name : String(tc.name ?? ''), @@ -503,7 +504,7 @@ function formatMothershipBlockOutput( return { content: result.content || '', model: result.model || 'mothership', - conversationId: result.conversationId || fallbackChatId, + conversationId, tokens: (result.tokens || {}) as NormalizedBlockOutput['tokens'], toolCalls, cost: result.cost as NormalizedBlockOutput['cost'] | undefined, @@ -605,7 +606,7 @@ async function readMothershipExecuteResponse( function createMothershipStreamingExecution( response: Response, - fallbackChatId: string, + conversationId: string, blockId: string, options: { onCancel?: (reason?: unknown) => void @@ -618,7 +619,7 @@ function createMothershipStreamingExecution( throw new Error('Sim execution stream ended without a response body') } - const output = formatMothershipBlockOutput({}, fallbackChatId) + const output = formatMothershipBlockOutput({}, conversationId) let reader: ReadableStreamDefaultReader | undefined let cancelled = false let cleanedUp = false @@ -664,7 +665,7 @@ function createMothershipStreamingExecution( if (event.type === 'final') { await consumeMothershipProvenance(event.data, response, options.registry) sawFinal = true - Object.assign(output, formatMothershipBlockOutput(event.data, fallbackChatId)) + Object.assign(output, formatMothershipBlockOutput(event.data, conversationId)) return } @@ -890,9 +891,10 @@ export class MothershipBlockHandler implements BlockHandler { content: modelInputProjection.value.prompt, }, ] - const providedConversationId = - typeof inputs.conversationId === 'string' ? inputs.conversationId.trim() : '' - const chatId = providedConversationId || generateId() + const { conversationId, chatId } = resolveMothershipConversation( + ctx.workspaceId ?? '', + inputs.conversationId + ) const messageId = generateId() const requestId = generateId() const secretMountPolicy = normalizeSecretMountPolicy({ @@ -1003,15 +1005,20 @@ export class MothershipBlockHandler implements BlockHandler { } if (isContentSelectedForStreaming(ctx, block)) { - const streamingExecution = createMothershipStreamingExecution(response, chatId, block.id, { - onCancel: (reason) => { - if (!abortController.signal.aborted) { - abortController.abort(reason ?? 'mothership_stream_cancelled') - } - }, - onDone: cleanupAbortListeners, - registry: resultRegistry, - }) + const streamingExecution = createMothershipStreamingExecution( + response, + conversationId, + block.id, + { + onCancel: (reason) => { + if (!abortController.signal.aborted) { + abortController.abort(reason ?? 'mothership_stream_cancelled') + } + }, + onDone: cleanupAbortListeners, + registry: resultRegistry, + } + ) streamingExecution.diagnosticResolvedSecretTraceRegistry = resultRegistry if (resultRegistry) ctx.resolvedSecretTraceRegistry = resultRegistry cleanupImmediately = false @@ -1019,7 +1026,7 @@ export class MothershipBlockHandler implements BlockHandler { } const result = await readMothershipExecuteResponse(response, resultRegistry) - const output = formatMothershipBlockOutput(result, chatId) + const output = formatMothershipBlockOutput(result, conversationId) if (resultRegistry) ctx.resolvedSecretTraceRegistry = resultRegistry return output } catch (error) { diff --git a/apps/sim/hooks/queries/github-search-setup.test.tsx b/apps/sim/hooks/queries/github-search-setup.test.tsx new file mode 100644 index 00000000000..e94931c00d2 --- /dev/null +++ b/apps/sim/hooks/queries/github-search-setup.test.tsx @@ -0,0 +1,162 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +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 { ApiClientError } from '@/lib/api/client/errors' +import { + type GitHubSearchSetupScope, + type GitHubSearchSetupStatus, + readGitHubSearchSetupContract, +} from '@/lib/api/contracts/knowledge/github-setup' + +const mocks = vi.hoisted(() => ({ request: vi.fn() })) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) + +import { githubSearchSetupKeys, useGitHubSearchSetup } from '@/hooks/queries/github-search-setup' + +const SCOPE: GitHubSearchSetupScope = { + organizationId: 'org-1', + setupId: 'cbe393bf-eb93-4b61-8e53-ce1dd602ba77', +} + +interface ProbeProps { + scope?: GitHubSearchSetupScope +} + +describe('GitHub setup status queries', () => { + let root: Root + let client: QueryClient + let result: ReturnType + + function Probe({ scope }: ProbeProps) { + result = useGitHubSearchSetup(scope) + return null + } + + async function advance(milliseconds: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + } + + async function render(scope?: GitHubSearchSetupScope) { + await act(async () => + root.render( + + + + ) + ) + await advance(1) + } + + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.request.mockReset() + mocks.request.mockResolvedValue({ success: true, data: { status: 'pending' } }) + client = new QueryClient({ defaultOptions: { queries: { retryDelay: 1 } } }) + root = createRoot(document.createElement('div')) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + client.clear() + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('does not request or poll without an active scope', async () => { + await render() + await advance(30_000) + expect(mocks.request).not.toHaveBeenCalled() + }) + + it('forwards the exact scope and aborts a read when its scope is replaced', async () => { + const signals: AbortSignal[] = [] + mocks.request.mockImplementation((_contract: unknown, input: { signal: AbortSignal }) => { + signals.push(input.signal) + return new Promise((_resolve, reject) => { + input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { + once: true, + }) + }) + }) + await render(SCOPE) + expect(mocks.request).toHaveBeenCalledWith(readGitHubSearchSetupContract, { + query: SCOPE, + signal: expect.any(AbortSignal), + }) + expect(signals[0].aborted).toBe(false) + + const otherScope = { ...SCOPE, organizationId: 'org-2' } + await render(otherScope) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + expect(mocks.request).toHaveBeenLastCalledWith(readGitHubSearchSetupContract, { + query: otherScope, + signal: signals[1], + }) + expect(client.getQueryData(githubSearchSetupKeys.detail(SCOPE))).toBeUndefined() + }) + + it.each([ + new TypeError('Failed to fetch'), + new ApiClientError({ status: 503, message: 'Temporarily unavailable', body: null }), + new ApiClientError({ status: 429, message: 'Try again later', body: null }), + ])('recovers a transient read failure: $message', async (failure) => { + mocks.request.mockRejectedValueOnce(failure) + await render(SCOPE) + await advance(5) + expect(result.data).toEqual({ status: 'pending' }) + expect(result.error).toBeNull() + expect(mocks.request).toHaveBeenCalledTimes(2) + + await advance(1500) + expect(mocks.request).toHaveBeenCalledTimes(3) + }) + + it('continues polling after transient errors exhaust the immediate retry budget', async () => { + const failure = new ApiClientError({ status: 503, message: 'Try again', body: null }) + mocks.request.mockRejectedValue(failure) + await render(SCOPE) + await advance(10) + expect(mocks.request).toHaveBeenCalledTimes(3) + expect(result.error).toBe(failure) + + mocks.request.mockResolvedValue({ success: true, data: { status: 'pending' } }) + await advance(5000) + expect(result.data).toEqual({ status: 'pending' }) + expect(result.error).toBeNull() + expect(mocks.request).toHaveBeenCalledTimes(4) + }) + + it('does not retry or poll after a terminal authorization denial', async () => { + const failure = new ApiClientError({ status: 403, message: 'Access denied', body: null }) + mocks.request.mockRejectedValue(failure) + await render(SCOPE) + await advance(30_000) + expect(result.error).toBe(failure) + expect(mocks.request).toHaveBeenCalledOnce() + }) + + it.each([ + { status: 'completed', credential: { id: 'installation-1', displayName: 'Acme' } }, + { status: 'failed', error: 'Choose a GitHub organization you own.' }, + { status: 'expired' }, + ])('stops polling after the server returns $status', async (terminalStatus) => { + await render(SCOPE) + expect(result.data).toEqual({ status: 'pending' }) + mocks.request.mockResolvedValue({ success: true, data: terminalStatus }) + await advance(1500) + expect(result.data).toEqual(terminalStatus) + expect(mocks.request).toHaveBeenCalledTimes(2) + + await advance(30_000) + expect(mocks.request).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/hooks/queries/github-search-setup.ts b/apps/sim/hooks/queries/github-search-setup.ts new file mode 100644 index 00000000000..2baf9dcd99a --- /dev/null +++ b/apps/sim/hooks/queries/github-search-setup.ts @@ -0,0 +1,77 @@ +'use client' + +import { useMutation, useQuery } from '@tanstack/react-query' +import { isApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + cancelGitHubSearchSetupContract, + type GitHubSearchSetupScope, + readGitHubSearchSetupContract, + type SelectGitHubSearchSetupBody, + type StartGitHubSearchSetupBody, + selectGitHubSearchSetupContract, + startGitHubSearchSetupContract, +} from '@/lib/api/contracts/knowledge/github-setup' + +export function isGitHubSetupTerminalError(error: unknown): boolean { + return ( + isApiClientError(error) && + error.status >= 400 && + error.status < 500 && + ![408, 429].includes(error.status) + ) +} + +export const githubSearchSetupKeys = { + all: ['github-search-setup'] as const, + details: () => [...githubSearchSetupKeys.all, 'detail'] as const, + detail: (scope?: GitHubSearchSetupScope) => + [ + ...githubSearchSetupKeys.details(), + scope?.organizationId ?? '', + scope?.setupId ?? '', + ] as const, +} + +export function useGitHubSearchSetup(scope?: GitHubSearchSetupScope) { + return useQuery({ + queryKey: githubSearchSetupKeys.detail(scope), + queryFn: async ({ signal }) => { + if (!scope) throw new Error('GitHub connection attempt is required') + return (await requestJson(readGitHubSearchSetupContract, { query: scope, signal })).data + }, + enabled: Boolean(scope), + staleTime: 0, + gcTime: 5 * 60_000, + retry: (failures, error) => !isGitHubSetupTerminalError(error) && failures < 2, + refetchInterval: (query) => + scope && + !isGitHubSetupTerminalError(query.state.error) && + (!query.state.data || ['pending', 'choosing'].includes(query.state.data.status)) + ? query.state.error + ? 5000 + : 1500 + : false, + }) +} + +export function useStartGitHubSearchSetup() { + return useMutation({ + mutationFn: (body: StartGitHubSearchSetupBody) => + requestJson(startGitHubSearchSetupContract, { body }), + }) +} + +export function useCancelGitHubSearchSetup() { + return useMutation({ + mutationFn: (body: GitHubSearchSetupScope) => + requestJson(cancelGitHubSearchSetupContract, { body }), + }) +} + +export function useSelectGitHubSearchSetup() { + return useMutation({ + mutationFn: (body: SelectGitHubSearchSetupBody) => + requestJson(selectGitHubSearchSetupContract, { body }), + }) +} diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index 457a1c648fe..cba05e736ad 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' const mocks = vi.hoisted(() => ({ @@ -118,6 +119,29 @@ function lastListStatusUpdater() { return call?.[1] as (connectors?: ConnectorData[]) => ConnectorData[] | undefined } +describe('connector permission cache reconciliation', () => { + beforeEach(() => vi.clearAllMocks()) + it.each([true, false])( + 'refreshes document visibility after permission saves (failed=%s)', + (failed) => { + useUpdateConnector() + const options = mocks.useMutation.mock.calls.at(-1)![0] + options.onSettled(undefined, failed ? new Error('Conflict') : null, { + knowledgeBaseId: KB_ID, + connectorId: 'connector-1', + updates: { permissionConfig: { provider: 'gitlab', mode: 'csv', expectedRevision: 1 } }, + }) + for (const queryKey of [ + knowledgeKeys.documentLists(KB_ID), + knowledgeKeys.documentDetails(KB_ID), + knowledgeKeys.searches(), + ]) { + expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + } + ) +}) + describe('isConnectorSyncingOrPending', () => { it('treats a queued sync as in flight', () => { expect(isConnectorSyncingOrPending(makeConnector({ status: 'pending' }))).toBe(true) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 8c524b253fb..f0c284635f2 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -35,8 +35,11 @@ import { type ViewerConnectorMembership, type WorkspaceMemberConnector, } from '@/lib/api/contracts/knowledge' +import type { + CreateConnectorBody, + UpdateConnectorBody, +} from '@/lib/api/contracts/knowledge/connectors' import { - type ConnectorAccessMode, type ConnectorDocumentsQuery, type PrepareSearchSourceBody, prepareSearchSourceContract, @@ -294,17 +297,7 @@ function optimisticallyQueueSync( return { status: cached.status } } -interface CreateConnectorParams { - knowledgeBaseId: string - connectorType: string - credentialId?: string - apiKey?: string - sourceConfig: Record - syncIntervalMinutes?: number - accessMode?: ConnectorAccessMode - credentialGroupId?: string - credentialGroupOptionId?: string -} +type CreateConnectorParams = CreateConnectorBody & { knowledgeBaseId: string } async function createConnector({ knowledgeBaseId, @@ -350,11 +343,7 @@ export function useCreateConnector() { interface UpdateConnectorParams { knowledgeBaseId: string connectorId: string - updates: { - sourceConfig?: Record - syncIntervalMinutes?: number - status?: 'active' | 'paused' - } + updates: UpdateConnectorBody } async function updateConnector({ @@ -392,10 +381,20 @@ export function useUpdateConnector() { }) } }, - onSettled: (_data, _error, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId, updates }) => { queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId) }) queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }) + if (updates.permissionConfig || updates.apiKey !== undefined) { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentLists(knowledgeBaseId) }) + queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentDetails(knowledgeBaseId) }) + queryClient.invalidateQueries({ + queryKey: knowledgeKeys.detail(knowledgeBaseId), + exact: true, + }) + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: knowledgeKeys.searches() }) + } }, }) } @@ -501,7 +500,13 @@ export function useOrganizationSearchOverview( export function useSearchSources( owner?: string | ResourceScope, - options?: { enabled?: boolean; search?: string; mine?: boolean; connectorType?: string } + options?: { + enabled?: boolean + search?: string + mine?: boolean + connectorType?: string + excludeConnectorType?: string + } ) { const queryClient = useQueryClient() const scope = @@ -517,6 +522,9 @@ export function useSearchSources( search: options?.search?.trim().toLowerCase() ?? '', mine: options?.mine ?? false, ...(options?.connectorType?.trim() ? { connectorType: options.connectorType.trim() } : {}), + ...(options?.excludeConnectorType?.trim() + ? { excludeConnectorType: options.excludeConnectorType.trim() } + : {}), } const summary = useInfiniteQuery({ queryKey: searchSourceKeys.pages(scope, filters), diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index b5773eb720d..69f33d1f871 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -13,6 +13,7 @@ import { ApiClientError } from '@/lib/api/client/errors' import { disconnectPersonalOrganizationAccountContract, listOrganizationAccountPeopleContract, + revokeOrganizationAccountEnrollmentContract, updateOrganizationAccountsContract, } from '@/lib/api/contracts/organization-accounts' import { resourceScopeKey } from '@/lib/core/resource-scope' @@ -20,6 +21,7 @@ import { organizationAccountsKeys, useDisconnectPersonalOrganizationAccount, useOrganizationAccountPeople, + useRevokeOrganizationAccountEnrollment, useUpdateOrganizationAccounts, } from '@/hooks/queries/organization-accounts' import { slackSearchKeys } from '@/hooks/queries/slack-search' @@ -207,6 +209,90 @@ describe('organization account setup updates', () => { ) }) +describe('organization account revocation', () => { + it.each([true, false])( + 'clears Search content and refreshes affected account projections only on success=%s', + async (success) => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.request.mockReset() + const response = Promise.withResolvers() + mocks.request.mockReturnValueOnce(response.promise) + const client = new QueryClient() + const root = createRoot(document.createElement('div')) + let mutation: ReturnType + function Probe() { + mutation = useRevokeOrganizationAccountEnrollment() + return null + } + const orgScope = { kind: 'organization', organizationId: 'org-1' } as const + const results = knowledgeKeys.search(resourceScopeKey(orgScope), 'private content') + const documentKey = knowledgeKeys.document('kb-1', 'doc-1') + const sources = searchSourceKeys.pages(orgScope, { mine: true, search: '' }) + const accounts = organizationAccountsKeys.detail('org-1') + const people = organizationAccountsKeys.peopleList('org-1', '', 'jira-option') + const overview = searchSourceKeys.organizationOverview('org-1') + const otherAccounts = organizationAccountsKeys.detail('org-2') + const otherResults = knowledgeKeys.search( + resourceScopeKey({ kind: 'organization', organizationId: 'org-2' }), + 'private content' + ) + for (const key of [ + results, + documentKey, + sources, + accounts, + people, + overview, + otherAccounts, + otherResults, + ]) + client.setQueryData(key, { content: 'cached' }) + try { + await act(async () => + root.render( + + + + ) + ) + let pending: Promise + await act(async () => { + pending = mutation.mutateAsync({ organizationId: 'org-1', enrollmentId: 'person-1' }) + }) + await act(async () => + root.render({null}) + ) + await act(async () => { + if (success) { + response.resolve({}) + await pending + } else { + const rejected = expect(pending).rejects.toThrow('Revocation failed') + response.reject(new Error('Revocation failed')) + await rejected + } + }) + expect(mocks.request).toHaveBeenCalledExactlyOnceWith( + revokeOrganizationAccountEnrollmentContract, + { params: { id: 'org-1', enrollmentId: 'person-1' } } + ) + for (const key of [results, documentKey, sources]) { + if (success) expect(client.getQueryData(key)).toBeUndefined() + else expect(client.getQueryData(key)).toEqual({ content: 'cached' }) + } + for (const key of [accounts, people, overview]) + expect(client.getQueryState(key)?.isInvalidated).toBe(success) + expect(client.getQueryState(otherAccounts)?.isInvalidated).toBe(false) + expect(client.getQueryData(otherResults)).toEqual({ content: 'cached' }) + } finally { + await act(async () => root.unmount()) + client.clear() + vi.unstubAllGlobals() + } + } + ) +}) + describe('organization people search pagination', () => { let root: Root let container: HTMLDivElement diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 65cf2cafef7..733c3f03927 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -28,6 +28,7 @@ import { type OrganizationAccountPeopleQuery, type RemoveOrganizationAccountMcpProviderParams, type ResendOrganizationAccountInvitationQuery, + reconnectPersonalOrganizationAccountContract, removeOrganizationAccountMcpProviderContract, resendOrganizationAccountInvitationContract, revokeOrganizationAccountEnrollmentContract, @@ -43,6 +44,13 @@ import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 +export function useReconnectPersonalOrganizationAccount() { + return useMutation({ + mutationFn: (credentialId: string) => + requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }), + }) +} + /** Disconnects an owned grant; indexing and source setup do not gate this operation. */ export function useDisconnectPersonalOrganizationAccount(organizationId: string) { const queryClient = useQueryClient() @@ -312,7 +320,12 @@ export function useRevokeOrganizationAccountEnrollment() { params: { id: organizationId, enrollmentId }, }), onSuccess: (_, { organizationId }) => - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.people(organizationId) }), + Promise.all([ + resetOrganizationSearchAccess(queryClient, organizationId), + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + ]), }) } export function useAddOrganizationAccountMcpProvider() { diff --git a/apps/sim/hooks/queries/organization-search-stats.ts b/apps/sim/hooks/queries/organization-search-stats.ts new file mode 100644 index 00000000000..a11c471103b --- /dev/null +++ b/apps/sim/hooks/queries/organization-search-stats.ts @@ -0,0 +1,28 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type OrganizationSearchStatsQuery, + readOrganizationSearchStatsContract, +} from '@/lib/api/contracts/knowledge/search-stats' + +export const ORGANIZATION_SEARCH_STATS_STALE_TIME = 60_000 + +export const organizationSearchStatsKeys = { + all: ['organization-search-stats'] as const, + summaries: () => [...organizationSearchStatsKeys.all, 'summary'] as const, + summary: (query: OrganizationSearchStatsQuery) => + [...organizationSearchStatsKeys.summaries(), query] as const, +} + +export function useOrganizationSearchStats(query: OrganizationSearchStatsQuery) { + return useQuery({ + queryKey: organizationSearchStatsKeys.summary(query), + queryFn: async ({ signal }) => { + const result = await requestJson(readOrganizationSearchStatsContract, { query, signal }) + return result.data + }, + staleTime: ORGANIZATION_SEARCH_STATS_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/personal-source-setup.ts b/apps/sim/hooks/queries/personal-source-setup.ts new file mode 100644 index 00000000000..25a3e0598a6 --- /dev/null +++ b/apps/sim/hooks/queries/personal-source-setup.ts @@ -0,0 +1,69 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + listPersonalSourceSetupAccountsContract, + type PersonalSourceSetupBody, + type PersonalSourceSetupQuery, + personalSourceSetupContract, +} from '@/lib/api/contracts/knowledge/personal-source-setup' +import { memberConnectorKeys } from '@/hooks/queries/kb/connectors' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { personalSearchIntegrationKeys } from '@/hooks/queries/personal-search-integrations' +import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' + +export const PERSONAL_SOURCE_SETUP_STALE_TIME = 15_000 + +export const personalSourceSetupKeys = { + all: ['personal-source-setup'] as const, + lists: () => [...personalSourceSetupKeys.all, 'list'] as const, + list: (query: PersonalSourceSetupQuery) => [...personalSourceSetupKeys.lists(), query] as const, +} + +export function usePersonalSourceSetupAccounts(query: PersonalSourceSetupQuery) { + return useQuery({ + queryKey: personalSourceSetupKeys.list(query), + queryFn: async ({ signal }) => + (await requestJson(listPersonalSourceSetupAccountsContract, { query, signal })).data, + staleTime: PERSONAL_SOURCE_SETUP_STALE_TIME, + refetchInterval: (state) => + query.completionId && !state.state.data?.completedCredentialId ? 1_500 : false, + }) +} + +export function useAuthorizePersonalSourceSetup() { + return useMutation({ + mutationFn: async (body: Extract) => { + const { data } = await requestJson(personalSourceSetupContract, { body }) + if (data.kind !== 'authorization') throw new Error('Could not start account authorization') + return data + }, + }) +} + +export function useConnectPersonalSourceSetup() { + const client = useQueryClient() + return useMutation({ + mutationFn: async (body: Extract) => { + const { data } = await requestJson(personalSourceSetupContract, { body }) + if (data.kind !== 'connected') throw new Error('Could not connect the source') + return data + }, + onSuccess: (_data, body) => + Promise.all([ + client.invalidateQueries({ queryKey: personalSourceSetupKeys.lists() }), + client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), + client.invalidateQueries({ queryKey: memberConnectorKeys.lists() }), + client.invalidateQueries({ + queryKey: searchSourceKeys.list({ + kind: 'organization', + organizationId: body.organizationId, + }), + }), + client.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(body.organizationId), + }), + ]), + }) +} diff --git a/apps/sim/hooks/queries/selectors.test.tsx b/apps/sim/hooks/queries/selectors.test.tsx index 21ac2bdc2d5..67cf3275117 100644 --- a/apps/sim/hooks/queries/selectors.test.tsx +++ b/apps/sim/hooks/queries/selectors.test.tsx @@ -7,15 +7,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExecuteSelectorRequest } = vi.hoisted(() => ({ +const { mockExecuteSelectorRequest, mockRequestJson } = vi.hoisted(() => ({ mockExecuteSelectorRequest: vi.fn(), + mockRequestJson: vi.fn(), })) vi.mock('@/lib/selectors/client/execute-selector', () => ({ executeSelectorRequest: mockExecuteSelectorRequest, })) -import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/queries/selectors' +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) + +import { + type SelectorLoadAllResult, + useSelectorOptionDetail, + useSelectorOptions, +} from '@/hooks/queries/selectors' interface HookHarness { getResult: () => T @@ -100,6 +107,276 @@ afterEach(() => { }) describe('generic selector queries', () => { + it('uses the dedicated personal setup contract and isolates it from ordinary browsing', async () => { + const personalItems = [{ id: 'PERSONAL', label: 'Personal project' }] + mockRequestJson.mockResolvedValue({ + success: true, + data: { kind: 'list', items: personalItems }, + }) + mockExecuteSelectorRequest.mockResolvedValue({ + kind: 'list', + items: [{ id: 'ADMIN', label: 'Admin project' }], + }) + const hook = renderHookWithClient(() => + useSelectorOptions('jira.projectKeys', { + context: { oauthCredential: 'credential-1', domain: 'example.atlassian.net' }, + scope: { kind: 'organization', organizationId: 'org-1' }, + surface: { kind: 'personal-search-setup', organizationId: 'org-1', connectorType: 'jira' }, + surfaceId: 'projects', + }) + ) + await waitFor(() => expect(hook.getResult().data).toEqual(personalItems)) + expect(mockExecuteSelectorRequest).not.toHaveBeenCalled() + expect(mockRequestJson).toHaveBeenCalledWith( + expect.objectContaining({ path: '/api/knowledge/sim-search/personal-source-setup' }), + expect.objectContaining({ + body: { + action: 'options', + organizationId: 'org-1', + connectorType: 'jira', + credentialId: 'credential-1', + domain: 'example.atlassian.net', + request: { kind: 'list' }, + }, + signal: expect.any(AbortSignal), + }) + ) + hook.rerender(() => + useSelectorOptions('jira.projectKeys', { + context: { oauthCredential: 'credential-1', domain: 'example.atlassian.net' }, + scope: { kind: 'organization', organizationId: 'org-1' }, + surfaceId: 'projects', + }) + ) + await waitFor(() => + expect(hook.getResult().data).toEqual([{ id: 'ADMIN', label: 'Admin project' }]) + ) + expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(1) + }) + + it('hydrates personal setup labels through the same dedicated contract', async () => { + mockRequestJson.mockResolvedValue({ + success: true, + data: { kind: 'detail', item: { id: 'ENG', label: 'Engineering' } }, + }) + const hook = renderHookWithClient(() => + useSelectorOptionDetail('confluence.spaces', { + context: { oauthCredential: 'credential-1', domain: 'example.atlassian.net' }, + scope: { kind: 'organization', organizationId: 'org-1' }, + surface: { + kind: 'personal-search-setup', + organizationId: 'org-1', + connectorType: 'confluence', + }, + detailId: 'ENG', + }) + ) + await waitFor(() => expect(hook.getResult().data?.id).toBe('ENG')) + expect(mockRequestJson).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.objectContaining({ + connectorType: 'confluence', + request: { kind: 'detail', id: 'ENG' }, + }), + }) + ) + expect(mockExecuteSelectorRequest).not.toHaveBeenCalled() + }) + + it.each(['selector', 'organization'] as const)( + 'rejects a mismatched personal setup %s before sending a request', + async (mismatch) => { + const hook = renderHookWithClient(() => + useSelectorOptions(mismatch === 'selector' ? 'confluence.spaces' : 'jira.projectKeys', { + context: { oauthCredential: 'credential-1', domain: 'example.atlassian.net' }, + scope: { + kind: 'organization', + organizationId: mismatch === 'organization' ? 'org-2' : 'org-1', + }, + surface: { + kind: 'personal-search-setup', + organizationId: 'org-1', + connectorType: 'jira', + }, + }) + ) + await waitFor(() => expect(hook.getResult().error).not.toBeNull()) + expect(mockRequestJson).not.toHaveBeenCalled() + expect(mockExecuteSelectorRequest).not.toHaveBeenCalled() + } + ) + + it.each(['flat', 'paged'] as const)('returns a complete already-loaded %s list', async (mode) => { + const items = [{ id: 'ENG', label: 'Engineering' }] + mockExecuteSelectorRequest.mockResolvedValue({ kind: 'list', items }) + const hook = renderHookWithClient(() => + useSelectorOptions(mode === 'paged' ? 'jira.projectKeys' : 'jira.issues', { + context: { + workspaceId: 'workspace-1', + oauthCredential: 'credential-1', + domain: 'example.atlassian.net', + }, + }) + ) + await waitFor(() => expect(hook.getResult().isSuccess).toBe(true)) + let result: SelectorLoadAllResult | undefined + await act(async () => { + result = await hook.getResult().loadAll() + }) + expect(result).toEqual({ status: 'complete', options: items }) + expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(1) + }) + + it.each(['provider', 'options', 'pages'] as const)( + 'reports %s truncation without a complete selection', + async (kind) => { + mockExecuteSelectorRequest.mockImplementation( + async ({ request }: { request: { cursor?: string } }) => ({ + kind: 'list', + items: + kind === 'options' + ? Array.from({ length: 10_001 }, (_, index) => ({ + id: String(index), + label: String(index), + })) + : [{ id: request.cursor ?? '0', label: 'Project' }], + ...(kind === 'pages' ? { nextCursor: String(Number(request.cursor ?? '0') + 1) } : {}), + ...(kind === 'provider' ? { truncated: true } : {}), + }) + ) + const hook = renderHookWithClient(() => + useSelectorOptions('jira.projectKeys', { + context: { + workspaceId: 'workspace-1', + oauthCredential: 'credential-1', + domain: 'example.atlassian.net', + }, + }) + ) + await waitFor(() => expect(hook.getResult().isSuccess).toBe(true)) + let result: SelectorLoadAllResult | undefined + await act(async () => { + result = await hook.getResult().loadAll() + }) + expect(result).toEqual({ status: 'partial' }) + expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(kind === 'pages' ? 200 : 1) + } + ) + + it('reports a failed continuation and retries from the first page', async () => { + let fail = true + mockExecuteSelectorRequest.mockImplementation( + async ({ request }: { request: { cursor?: string } }) => { + if (request.cursor && fail) throw new Error('Provider is unavailable') + return request.cursor + ? { kind: 'list', items: [{ id: 'OPS', label: 'Operations' }] } + : { kind: 'list', items: [{ id: 'ENG', label: 'Engineering' }], nextCursor: 'next' } + } + ) + const hook = renderHookWithClient(() => + useSelectorOptions('jira.projectKeys', { + context: { + workspaceId: 'workspace-1', + oauthCredential: 'credential-1', + domain: 'example.atlassian.net', + }, + }) + ) + await waitFor(() => expect(hook.getResult().hasMore).toBe(true)) + let result: SelectorLoadAllResult | undefined + await act(async () => { + result = await hook.getResult().loadAll() + }) + expect(result).toEqual({ status: 'error' }) + await waitFor(() => expect(hook.getResult().error).toBeTruthy()) + fail = false + await act(async () => { + result = await hook.getResult().loadAll() + }) + expect(result).toEqual({ + status: 'complete', + options: [ + { id: 'ENG', label: 'Engineering' }, + { id: 'OPS', label: 'Operations' }, + ], + }) + expect(mockExecuteSelectorRequest.mock.calls.map(([args]) => args.request.cursor)).toEqual([ + undefined, + 'next', + undefined, + 'next', + ]) + }) + + it.each(['scope', 'surface', 'credential', 'site', 'search', 'disabled', 'unmount'] as const)( + 'cancels bulk selection when its %s changes', + async (change) => { + let resolvePage!: (result: { + kind: 'list' + items: { id: string; label: string }[] + nextCursor?: string + }) => void + const pending = new Promise<{ + kind: 'list' + items: { id: string; label: string }[] + nextCursor?: string + }>((resolve) => { + resolvePage = resolve + }) + mockExecuteSelectorRequest.mockImplementation( + ({ request }: { request: { cursor?: string } }) => + request.cursor + ? pending + : Promise.resolve({ + kind: 'list', + items: [{ id: 'ENG', label: 'Engineering' }], + nextCursor: 'next', + }) + ) + let workspaceId = 'workspace-1' + let surfaceId = 'field-1' + let credentialId = 'credential-1' + let domain = 'example.atlassian.net' + let search = '' + let enabled = true + const useHook = () => + useSelectorOptions('jira.projectKeys', { + context: { workspaceId, oauthCredential: credentialId, domain }, + surfaceId, + search, + enabled, + }) + const hook = renderHookWithClient(useHook) + await waitFor(() => expect(hook.getResult().hasMore).toBe(true)) + let result!: Promise + act(() => { + result = hook.getResult().loadAll() + }) + await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(2)) + if (change === 'scope') workspaceId = 'workspace-2' + if (change === 'surface') surfaceId = 'field-2' + if (change === 'credential') credentialId = 'credential-2' + if (change === 'site') domain = 'another.atlassian.net' + if (change === 'search') search = 'Operations' + if (change === 'disabled') enabled = false + if (change === 'unmount') hook.unmount() + else hook.rerender(useHook) + await act(async () => { + resolvePage({ + kind: 'list', + items: [{ id: 'OPS', label: 'Operations' }], + nextCursor: 'unused', + }) + expect(await result).toEqual({ status: 'cancelled' }) + }) + expect( + mockExecuteSelectorRequest.mock.calls.filter(([args]) => args.request.cursor) + ).toHaveLength(1) + if (change !== 'unmount') expect(hook.getResult().isLoadingAll).toBe(false) + } + ) + it('transports supported search and keeps context and request plaintext out of query keys', async () => { const credentialReference = '{{SHARED_GOOGLE_CREDENTIAL}}' const search = 'private search phrase' @@ -365,7 +642,10 @@ describe('generic selector queries', () => { await waitFor(() => expect(hook.getResult().hasMore).toBe(true)) expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(1) - act(() => hook.getResult().loadAll()) + let result: SelectorLoadAllResult | undefined + await act(async () => { + result = await hook.getResult().loadAll() + }) await waitFor(() => expect(hook.getResult().isLoadingAll).toBe(false)) expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(3) @@ -375,6 +655,7 @@ describe('generic selector queries', () => { { id: 'workspace-2', label: 'Workspace 2' }, ]) expect(hook.getResult()).toMatchObject({ hasMore: false, truncated: false }) + expect(result).toEqual({ status: 'complete', options: hook.getResult().data }) }) it('refreshes from the first page before retrying a failed continuation cursor', async () => { diff --git a/apps/sim/hooks/queries/selectors.ts b/apps/sim/hooks/queries/selectors.ts index c30671f41b5..a92dcf537c1 100644 --- a/apps/sim/hooks/queries/selectors.ts +++ b/apps/sim/hooks/queries/selectors.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' -import { executeSelectorRequest } from '@/lib/selectors/client/execute-selector' +import { requestJson } from '@/lib/api/client/request' +import { personalSourceSetupContract } from '@/lib/api/contracts/knowledge/personal-source-setup' +import { + type ExecuteSelectorClientInput, + executeSelectorRequest, +} from '@/lib/selectors/client/execute-selector' import { projectSelectorContext } from '@/lib/selectors/context' import { MAX_SELECTOR_OPTIONS, MAX_SELECTOR_PAGES } from '@/lib/selectors/limits' import { @@ -16,12 +21,12 @@ import type { SelectorOption, SelectorPage, SelectorScope, + SelectorSurface, } from '@/lib/selectors/types' import { selectorKeys } from '@/hooks/queries/utils/selector-keys' type SelectorListResult = Extract -const EMPTY_PAGE: SelectorListResult = { kind: 'list', items: [] } let nextOpaqueRevision = 1 export type SelectorClientContext = SelectorContext & { @@ -35,6 +40,38 @@ interface SelectorHookArgs { search?: string enabled?: boolean surfaceId?: string + surface?: SelectorSurface +} + +async function executeForSurface( + input: ExecuteSelectorClientInput, + surface?: SelectorSurface +): Promise { + if (!surface) return executeSelectorRequest(input) + const expectedKey = surface.connectorType === 'jira' ? 'jira.projectKeys' : 'confluence.spaces' + if ( + input.selectorKey !== expectedKey || + input.scope?.kind !== 'organization' || + input.scope.organizationId !== surface.organizationId || + !input.context.oauthCredential || + !input.context.domain + ) + throw new Error('This selector is not available during personal source setup') + const result = await requestJson(personalSourceSetupContract, { + body: { + action: 'options', + organizationId: surface.organizationId, + connectorType: surface.connectorType, + credentialId: input.context.oauthCredential, + domain: input.context.domain, + request: input.request, + }, + signal: input.signal, + }) + if (result.data.kind !== 'list' && result.data.kind !== 'detail') { + throw new Error('Personal source setup returned an unexpected selector result') + } + return result.data } export interface SelectorOptionsResult { @@ -48,10 +85,14 @@ export interface SelectorOptionsResult { error: Error | null isSuccess: boolean loadMore(): void - loadAll(): void + loadAll(): Promise refetch(): void } +export type SelectorLoadAllResult = + | { status: 'complete'; options: SelectorOption[] } + | { status: 'partial' | 'error' | 'cancelled' } + interface CollectedSelectorOptions { options: SelectorOption[] overflowed: boolean @@ -140,7 +181,13 @@ function usePreparedSelector( const context = projectSelectorContext(key, args.context) const scope = selectorScopeFromContext(args.context, args.scope) const contextValues = manifest.context.allowed.map((field) => context[field]) - const revision = useOpaqueRevision([...contextValues, ...requestValues]) + const revision = useOpaqueRevision([ + ...contextValues, + ...requestValues, + args.surface?.kind, + args.surface?.organizationId, + args.surface?.connectorType, + ]) const ready = args.enabled !== false && isSelectorReady(key, context) && @@ -152,6 +199,7 @@ function usePreparedSelector( revision, ready, surfaceId: args.surfaceId ?? generatedSurfaceId, + surface: args.surface, } } @@ -174,16 +222,19 @@ export function useSelectorOptions( // rq-lint-allow: context and search are represented by an opaque privacy revision. queryKey: baseKey, queryFn: async ({ signal }) => { - const result = await executeSelectorRequest({ - selectorKey: key, - scope: prepared.scope, - context: prepared.context, - request: { - kind: 'list', - ...(effectiveSearch !== undefined ? { search: effectiveSearch } : {}), + const result = await executeForSurface( + { + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { + kind: 'list', + ...(effectiveSearch !== undefined ? { search: effectiveSearch } : {}), + }, + signal, }, - signal, - }) + prepared.surface + ) if (result.kind !== 'list') throw new Error('Selector returned an unexpected detail result') return result }, @@ -196,18 +247,21 @@ export function useSelectorOptions( // rq-lint-allow: context and search are represented by an opaque privacy revision. queryKey: [...baseKey, 'paged'], queryFn: async ({ pageParam, signal }) => { - const result = await executeSelectorRequest({ - selectorKey: key, - scope: prepared.scope, - context: prepared.context, - request: { - kind: 'list', - ...(effectiveSearch !== undefined ? { search: effectiveSearch } : {}), - ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + const result = await executeForSurface( + { + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { + kind: 'list', + ...(effectiveSearch !== undefined ? { search: effectiveSearch } : {}), + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + signal, }, - signal, - }) - if (result.kind !== 'list') return EMPTY_PAGE + prepared.surface + ) + if (result.kind !== 'list') throw new Error('Selector returned an unexpected detail result') return result }, getNextPageParam: (last) => last.nextCursor, @@ -231,73 +285,88 @@ export function useSelectorOptions( const loadGenerationRef = useRef(0) const pageFetchInFlightRef = useRef(false) const [isLoadingAll, setIsLoadingAll] = useState(false) + const requestIdentity = JSON.stringify(baseKey) useEffect(() => { loadGenerationRef.current += 1 pageFetchInFlightRef.current = false setIsLoadingAll(false) - }, [key, prepared.revision]) + return () => { + loadGenerationRef.current += 1 + pageFetchInFlightRef.current = false + } + }, [requestIdentity, prepared.ready]) const loadMore = useCallback(() => { if (!canLoadMore || pageFetchInFlightRef.current) return + const generation = ++loadGenerationRef.current pageFetchInFlightRef.current = true void (async () => { if (pagedQuery.isFetchNextPageError) { const refreshed = await pagedQuery.refetch() + if (loadGenerationRef.current !== generation) return const refreshedPages = refreshed.data?.pages const refreshedLastPage = refreshedPages?.[refreshedPages.length - 1] if (refreshed.isError || !refreshedLastPage?.nextCursor) return } await pagedQuery.fetchNextPage() })().finally(() => { - pageFetchInFlightRef.current = false + if (loadGenerationRef.current === generation) pageFetchInFlightRef.current = false }) }, [canLoadMore, pagedQuery.fetchNextPage, pagedQuery.isFetchNextPageError, pagedQuery.refetch]) - const loadAll = useCallback(() => { - if (!canLoadMore || pageFetchInFlightRef.current) return - const generation = loadGenerationRef.current + 1 - loadGenerationRef.current = generation + const loadAll = useCallback(async (): Promise => { + if (!prepared.ready || pageFetchInFlightRef.current || pagedQuery.isFetching) { + return { status: 'cancelled' } + } + const generation = ++loadGenerationRef.current pageFetchInFlightRef.current = true setIsLoadingAll(true) - void (async () => { - let hasNextPage = Boolean(pagedQuery.hasNextPage) - let pages = pagedQuery.data?.pages - try { - if (pagedQuery.isFetchNextPageError) { - const refreshed = await pagedQuery.refetch() - if (loadGenerationRef.current !== generation || refreshed.isError) return - pages = refreshed.data?.pages - const refreshedLastPage = pages?.[pages.length - 1] - hasNextPage = Boolean(refreshedLastPage?.nextCursor) - } - while (hasNextPage) { - if ( - (pages?.length ?? 0) >= MAX_SELECTOR_PAGES || - collectSelectorOptions(pages).options.length >= MAX_SELECTOR_OPTIONS - ) { - break - } - const result = await pagedQuery.fetchNextPage() - if (loadGenerationRef.current !== generation) return - if (result.isError) break - hasNextPage = Boolean(result.hasNextPage) - pages = result.data?.pages - } - } finally { - if (loadGenerationRef.current === generation) { - pageFetchInFlightRef.current = false - setIsLoadingAll(false) - } + let hasNextPage = Boolean(pagedQuery.hasNextPage) + let pages = pagedQuery.data?.pages + try { + if (pagedQuery.isError || !pages) { + const refreshed = await pagedQuery.refetch() + if (loadGenerationRef.current !== generation) return { status: 'cancelled' } + if (refreshed.isError) return { status: 'error' } + pages = refreshed.data?.pages + const refreshedLastPage = pages?.[pages.length - 1] + hasNextPage = Boolean(refreshedLastPage?.nextCursor) } - })() + while (hasNextPage) { + const collected = collectSelectorOptions(pages) + if ( + (pages?.length ?? 0) >= MAX_SELECTOR_PAGES || + collected.options.length >= MAX_SELECTOR_OPTIONS || + pages?.some((page) => page.truncated) + ) + return { status: 'partial' } + const result = await pagedQuery.fetchNextPage() + if (loadGenerationRef.current !== generation) return { status: 'cancelled' } + if (result.isError) return { status: 'error' } + hasNextPage = Boolean(result.hasNextPage) + pages = result.data?.pages + } + const collected = collectSelectorOptions(pages) + return collected.overflowed || pages?.some((page) => page.truncated) + ? { status: 'partial' } + : { status: 'complete', options: collected.options } + } catch { + return { status: loadGenerationRef.current === generation ? 'error' : 'cancelled' } + } finally { + if (loadGenerationRef.current === generation) { + pageFetchInFlightRef.current = false + setIsLoadingAll(false) + } + } }, [ - canLoadMore, + prepared.ready, pagedQuery.data?.pages, pagedQuery.fetchNextPage, pagedQuery.hasNextPage, - pagedQuery.isFetchNextPageError, + pagedQuery.isError, + pagedQuery.isFetching, pagedQuery.refetch, ]) @@ -337,7 +406,14 @@ export function useSelectorOptions( error: (flatQuery.error as Error | null) ?? null, isSuccess: flatQuery.isSuccess, loadMore: () => undefined, - loadAll: () => undefined, + loadAll: async () => { + if (!prepared.ready || flatQuery.isFetching) return { status: 'cancelled' } + if (flatQuery.isError || !flatQuery.data) return { status: 'error' } + const collected = collectSelectorOptions([flatQuery.data]) + return flatQuery.data.truncated || collected.overflowed + ? { status: 'partial' } + : { status: 'complete', options: collected.options } + }, refetch: () => { if (!prepared.ready) return void flatQuery.refetch() @@ -365,13 +441,16 @@ export function useSelectorOptionDetail( prepared.revision ), queryFn: async ({ signal }) => { - const result = await executeSelectorRequest({ - selectorKey: key, - scope: prepared.scope, - context: prepared.context, - request: { kind: 'detail', id: args.detailId! }, - signal, - }) + const result = await executeForSurface( + { + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { kind: 'detail', id: args.detailId! }, + signal, + }, + prepared.surface + ) if (result.kind !== 'detail') throw new Error('Selector returned an unexpected list result') return result.item }, @@ -399,13 +478,16 @@ export function useSelectorOptionDetails( ordinal ), queryFn: async ({ signal }: { signal: AbortSignal }) => { - const result = await executeSelectorRequest({ - selectorKey: key, - scope: prepared.scope, - context: prepared.context, - request: { kind: 'detail', id: detailId }, - signal, - }) + const result = await executeForSurface( + { + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { kind: 'detail', id: detailId }, + signal, + }, + prepared.surface + ) if (result.kind !== 'detail') throw new Error('Selector returned an unexpected list result') return result.item }, diff --git a/apps/sim/hooks/queries/utils/search-source-keys.ts b/apps/sim/hooks/queries/utils/search-source-keys.ts index 17f69eb5683..ee508d3fb1b 100644 --- a/apps/sim/hooks/queries/utils/search-source-keys.ts +++ b/apps/sim/hooks/queries/utils/search-source-keys.ts @@ -12,7 +12,12 @@ export const searchSourceKeys = { ] as const, pages: ( scope: string | ResourceScope | undefined, - filters: { search: string; mine: boolean; connectorType?: string } + filters: { + search: string + mine: boolean + connectorType?: string + excludeConnectorType?: string + } ) => [...searchSourceKeys.list(scope), 'pages', filters] as const, overview: (scope?: string | ResourceScope) => [...searchSourceKeys.list(scope), 'overview'] as const, diff --git a/apps/sim/hooks/use-github-installation-setup-cache.test.tsx b/apps/sim/hooks/use-github-installation-setup-cache.test.tsx new file mode 100644 index 00000000000..b57c63752cd --- /dev/null +++ b/apps/sim/hooks/use-github-installation-setup-cache.test.tsx @@ -0,0 +1,178 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +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 { + readGitHubSearchSetupContract, + startGitHubSearchSetupContract, +} from '@/lib/api/contracts/knowledge/github-setup' +import { + listOrganizationCredentialsContract, + listOrganizationOAuthCredentialsContract, +} from '@/lib/api/contracts/organization-credentials' + +const mocks = vi.hoisted(() => ({ + request: vi.fn<(contract: unknown, input: RequestInput) => Promise>(), + connected: vi.fn(), +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) +vi.mock('@/hooks/queries/credentials', () => ({ useWorkspaceCredential: vi.fn() })) + +import { oauthCredentialKeys, useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup' + +interface RequestInput { + query?: { organizationId?: string; purpose?: string } +} + +describe('GitHub setup credential cache reconciliation', () => { + let root: Root + let client: QueryClient + let setup: ReturnType + let indexing: ReturnType + let browsing: ReturnType + let unrelated: ReturnType + let completed: boolean + let channels: Array<{ onmessage: ((event: MessageEvent) => void) | null }> + + function Probe() { + indexing = useOAuthCredentials('github-repositories', { organizationId: 'org-1' }) + browsing = useOAuthCredentials('github-repositories', { + organizationId: 'org-1', + purpose: 'browsing', + }) + unrelated = useOAuthCredentials('github-repositories', { organizationId: 'org-2' }) + setup = useGitHubInstallationSetup({ + organizationId: 'org-1', + onConnected: mocks.connected, + }) + return null + } + + async function flush() { + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + } + + function accountRequests(organizationId: string) { + return mocks.request.mock.calls.filter( + ([contract, input]: [unknown, RequestInput]) => + contract === listOrganizationCredentialsContract && + input.query?.organizationId === organizationId + ) + } + + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.request.mockReset() + mocks.connected.mockReset() + completed = false + channels = [] + vi.stubGlobal( + 'BroadcastChannel', + class { + onmessage = null + close = vi.fn() + constructor() { + channels.push(this) + } + } + ) + const tab = { opener: {}, location: { href: 'about:blank' }, focus: vi.fn(), close: vi.fn() } + vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) + mocks.request.mockImplementation(async (contract: unknown, input: RequestInput) => { + if (contract === listOrganizationOAuthCredentialsContract) return { credentials: [] } + if (contract === listOrganizationCredentialsContract) { + const organizationId = input.query?.organizationId + return { + credentials: [ + { + id: `${organizationId}-existing`, + displayName: 'Existing installation', + providerId: 'github-app-installation', + }, + ...(completed && organizationId === 'org-1' + ? [ + { + id: 'installation-new', + displayName: 'New organization', + providerId: 'github-app-installation', + }, + ] + : []), + ], + } + } + if (contract === startGitHubSearchSetupContract) + return { success: true, url: 'https://github.com/apps/sim/installations/new' } + if (contract === readGitHubSearchSetupContract) + return { + success: true, + data: completed + ? { + status: 'completed', + credential: { id: 'installation-new', displayName: 'New organization' }, + } + : { status: 'pending' }, + } + throw new Error('Unexpected request') + }) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + root = createRoot(document.createElement('div')) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + client.clear() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('refreshes mounted Add and Settings lists on completion without a visibility event', async () => { + await act(async () => + root.render( + + + + ) + ) + await flush() + expect(indexing.data?.map(({ id }) => id)).toEqual(['org-1-existing']) + expect(browsing.data?.map(({ id }) => id)).toEqual(['org-1-existing']) + expect(accountRequests('org-1')).toHaveLength(2) + expect(accountRequests('org-2')).toHaveLength(1) + + await act(async () => setup.connect()) + await flush() + expect(setup.pending).toBe(true) + completed = true + await act(async () => { + channels[0].onmessage?.({ data: 'connected' } as MessageEvent) + }) + await flush() + await flush() + + expect(mocks.connected).toHaveBeenCalledExactlyOnceWith('installation-new') + expect(setup.pending).toBe(false) + expect(indexing.data?.map(({ id }) => id)).toEqual(['org-1-existing', 'installation-new']) + expect(browsing.data?.map(({ id }) => id)).toEqual(['org-1-existing', 'installation-new']) + expect(accountRequests('org-1')).toHaveLength(4) + expect(accountRequests('org-2')).toHaveLength(1) + expect(unrelated.data?.map(({ id }) => id)).toEqual(['org-2-existing']) + expect( + client.getQueryData(oauthCredentialKeys.list('github-repositories', '', '', 'org-1')) + ).toEqual(indexing.data) + expect( + client.getQueryData( + oauthCredentialKeys.list('github-repositories', '', '', 'org-1', 'browsing') + ) + ).toEqual(browsing.data) + }) +}) diff --git a/apps/sim/hooks/use-github-installation-setup.test.tsx b/apps/sim/hooks/use-github-installation-setup.test.tsx new file mode 100644 index 00000000000..688e036e2c8 --- /dev/null +++ b/apps/sim/hooks/use-github-installation-setup.test.tsx @@ -0,0 +1,224 @@ +/** + * @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 { ApiClientError } from '@/lib/api/client/errors' +import type { GitHubSearchSetupStatus } from '@/lib/api/contracts/knowledge/github-setup' + +const mocks = vi.hoisted(() => ({ + start: vi.fn(), + cancel: vi.fn(), + refetch: vi.fn(), + invalidate: vi.fn(), + connected: vi.fn(), + status: undefined as GitHubSearchSetupStatus | undefined, + error: null as Error | null, +})) +const client = { invalidateQueries: mocks.invalidate } +vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => client })) +vi.mock('@/hooks/queries/github-search-setup', () => ({ + isGitHubSetupTerminalError: (error: unknown) => + error instanceof ApiClientError && error.status === 403, + useStartGitHubSearchSetup: () => ({ mutateAsync: mocks.start, isPending: false }), + useCancelGitHubSearchSetup: () => ({ mutateAsync: mocks.cancel }), + useGitHubSearchSetup: () => ({ + data: mocks.status, + error: mocks.error, + refetch: mocks.refetch, + }), +})) + +import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup' + +describe('GitHub installation setup handoff', () => { + let root: Root + let container: HTMLDivElement + let current: ReturnType + let channels: Array<{ + onmessage: ((event: MessageEvent) => void) | null + close: ReturnType + }> + let tab: { + opener: unknown + location: { href: string } + focus: ReturnType + close: ReturnType + } + function Probe({ organizationId = 'org-1' }: { organizationId?: string }) { + current = useGitHubInstallationSetup({ organizationId, onConnected: mocks.connected }) + return null + } + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mocks.status = undefined + mocks.error = null + mocks.start.mockResolvedValue({ url: 'https://github.com/login/oauth/authorize' }) + mocks.cancel.mockResolvedValue({ success: true }) + channels = [] + vi.stubGlobal( + 'BroadcastChannel', + class { + onmessage = null + close = vi.fn() + constructor() { + channels.push(this) + } + } + ) + tab = { opener: {}, location: { href: 'about:blank' }, focus: vi.fn(), close: vi.fn() } + vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root.render()) + }) + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('opens approval synchronously and completes only from the authorized server receipt', async () => { + await act(async () => current.connect()) + expect(tab.opener).toBeNull() + expect(tab.location.href).toBe('https://github.com/login/oauth/authorize') + expect(mocks.start).toHaveBeenCalledWith({ + organizationId: 'org-1', + setupId: expect.any(String), + }) + expect(current.pending).toBe(true) + act(() => channels[0].onmessage?.({ data: 'connected' } as MessageEvent)) + expect(mocks.refetch).toHaveBeenCalledOnce() + expect(mocks.connected).not.toHaveBeenCalled() + mocks.status = { + status: 'completed', + credential: { id: 'installation-1', displayName: 'Acme' }, + } + act(() => root.render()) + expect(current.pending).toBe(false) + expect(mocks.connected).toHaveBeenCalledExactlyOnceWith('installation-1') + expect(tab.close).toHaveBeenCalledOnce() + expect(mocks.invalidate).toHaveBeenCalledTimes(4) + expect(mocks.invalidate).toHaveBeenCalledWith({ + queryKey: ['oauthCredentials', 'list', 'github-repositories', '', '', 'org-1', 'browsing'], + }) + act(() => root.render()) + expect(mocks.connected).toHaveBeenCalledOnce() + }) + + it('keeps one approval attempt and focuses it when Connect is clicked again', async () => { + await act(async () => current.connect()) + await act(async () => current.connect()) + expect(window.open).toHaveBeenCalledOnce() + expect(mocks.start).toHaveBeenCalledOnce() + expect(tab.focus).toHaveBeenCalledOnce() + }) + + it('cancels without navigating a late start response or accepting its completion', async () => { + let resolve!: (value: { url: string }) => void + mocks.start.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + let connecting!: Promise + act(() => { + connecting = current.connect() + }) + act(() => current.cancel()) + await act(async () => { + resolve({ url: 'https://github.com/apps/sim/installations/new' }) + await connecting + }) + expect(tab.location.href).toBe('about:blank') + expect(mocks.cancel).toHaveBeenCalledWith(mocks.start.mock.calls[0][0]) + mocks.status = { status: 'completed', credential: { id: 'cancelled', displayName: 'Acme' } } + act(() => root.render()) + expect(mocks.connected).not.toHaveBeenCalled() + expect(current.pending).toBe(false) + }) + + it('shows denied approval inline and allows a new attempt', async () => { + await act(async () => current.connect()) + act(() => channels[0].onmessage?.({ data: 'denied' } as MessageEvent)) + expect(current.pending).toBe(true) + expect(mocks.refetch).toHaveBeenCalledOnce() + mocks.status = { status: 'failed', error: 'Choose a GitHub organization you own.' } + act(() => root.render()) + expect(current.error).toBe('Choose a GitHub organization you own.') + expect(current.pending).toBe(false) + mocks.status = undefined + await act(async () => current.connect()) + expect(mocks.start).toHaveBeenCalledTimes(2) + expect(current.error).toBeNull() + }) + + it('cancels on organization change and ignores the previous receipt', async () => { + await act(async () => current.connect()) + mocks.status = { status: 'completed', credential: { id: 'wrong-org', displayName: 'Acme' } } + act(() => root.render()) + expect(mocks.cancel).toHaveBeenCalledWith(mocks.start.mock.calls[0][0]) + expect(mocks.connected).not.toHaveBeenCalled() + expect(current.pending).toBe(false) + }) + + it('keeps the approval open through transient polling failures but stops on lost access', async () => { + await act(async () => current.connect()) + mocks.error = new Error('Failed to fetch') + act(() => root.render()) + expect(current.pending).toBe(true) + expect(mocks.cancel).not.toHaveBeenCalled() + mocks.error = new ApiClientError({ + status: 403, + message: 'Organization access denied', + body: null, + }) + act(() => root.render()) + expect(current.pending).toBe(false) + expect(current.error).toBe('Organization access denied') + expect(mocks.cancel).toHaveBeenCalledOnce() + }) + + it('forwards an explicit new-organization request instead of reusing an existing installation', async () => { + await act(async () => current.connect('install')) + expect(mocks.start).toHaveBeenCalledWith({ + organizationId: 'org-1', + setupId: expect.any(String), + intent: 'install', + }) + }) + + it('bounds the attempt lifetime and cleans up its channel', async () => { + await act(async () => current.connect()) + act(() => vi.advanceTimersByTime(10 * 60_000)) + expect(current.pending).toBe(false) + expect(current.error).toContain('timed out') + expect(channels[0].close).toHaveBeenCalledOnce() + expect(mocks.cancel).toHaveBeenCalledOnce() + }) + + it('reports popup blocking before creating any server attempt', async () => { + vi.mocked(window.open).mockReturnValue(null) + await act(async () => current.connect()) + expect(mocks.start).not.toHaveBeenCalled() + expect(current.error).toContain('Allow pop-ups') + }) + + it.each([ + 'https://attacker.example/authorize', + 'javascript:alert(1)', + 'https://github.com@attacker.example', + ])('rejects an unexpected approval destination: %s', async (url) => { + mocks.start.mockResolvedValue({ url }) + await act(async () => current.connect()) + expect(tab.location.href).toBe('about:blank') + expect(current.error).toBe('GitHub returned an invalid setup URL') + expect(mocks.cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/hooks/use-github-installation-setup.ts b/apps/sim/hooks/use-github-installation-setup.ts new file mode 100644 index 00000000000..762a6dd222d --- /dev/null +++ b/apps/sim/hooks/use-github-installation-setup.ts @@ -0,0 +1,172 @@ +'use client' + +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 type { StartGitHubSearchSetupBody } from '@/lib/api/contracts/knowledge/github-setup' +import { + credentialGroupOAuthCompletionChannel, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' +import { resolveGitHubSetupUrl } from '@/lib/knowledge/github-setup-navigation' +import { githubSearchInstallationKeys } from '@/hooks/queries/github-search-installations' +import { + isGitHubSetupTerminalError, + useCancelGitHubSearchSetup, + useGitHubSearchSetup, + useStartGitHubSearchSetup, +} from '@/hooks/queries/github-search-setup' +import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' + +interface GitHubInstallationSetupProps { + organizationId?: string + onConnected: (credentialId: string) => void +} + +/** Keeps the source form in place; only an authorized server result completes setup. */ +export function useGitHubInstallationSetup({ + organizationId, + onConnected, +}: GitHubInstallationSetupProps) { + const active = useRef<{ setupId: string; tab: Window } | null>(null) + const callback = useRef(onConnected) + const [setupId, setSetupId] = useState() + const [error, setError] = useState(null) + const [previousOrganizationId, setPreviousOrganizationId] = useState(organizationId) + if (previousOrganizationId !== organizationId) { + setPreviousOrganizationId(organizationId) + setSetupId(undefined) + setError(null) + } + const client = useQueryClient() + const { mutateAsync: start, isPending: isStarting } = useStartGitHubSearchSetup() + const { mutateAsync: cancelSetup } = useCancelGitHubSearchSetup() + const scope = organizationId && setupId ? { organizationId, setupId } : undefined + const query = useGitHubSearchSetup(scope) + const { refetch } = query + + useEffect(() => { + callback.current = onConnected + }, [onConnected]) + + useEffect(() => { + return () => { + const attempt = active.current + active.current = null + attempt?.tab.close() + if (attempt && organizationId) + void cancelSetup({ organizationId, setupId: attempt.setupId }).catch(() => undefined) + } + }, [organizationId, cancelSetup]) + + useEffect(() => { + if (!setupId || !organizationId) return + const fail = (message: string) => { + if (active.current?.setupId !== setupId) return + active.current.tab.close() + active.current = null + setSetupId(undefined) + setError(message) + void cancelSetup({ organizationId, setupId }).catch(() => undefined) + } + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(setupId)) + channel.onmessage = ({ data }: MessageEvent) => { + if (isCredentialGroupOAuthFailure(data) || data === 'connected') void refetch() + } + const timer = window.setTimeout( + () => fail('GitHub connection timed out. Try connecting again.'), + 10 * 60_000 + ) + return () => { + channel.close() + window.clearTimeout(timer) + } + }, [organizationId, setupId, cancelSetup, refetch]) + + useEffect(() => { + if (!setupId || !organizationId || active.current?.setupId !== setupId) return + const result = query.data + if (result?.status === 'completed') { + active.current.tab.close() + active.current = null + setSetupId(undefined) + for (const purpose of [undefined, 'browsing'] as const) { + void client.invalidateQueries({ + queryKey: oauthCredentialKeys.list( + 'github-repositories', + '', + '', + organizationId, + purpose + ), + }) + } + void client.invalidateQueries({ queryKey: githubSearchInstallationKeys.list(organizationId) }) + void client.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId) }) + callback.current(result.credential.id) + } else if ( + isGitHubSetupTerminalError(query.error) || + result?.status === 'failed' || + result?.status === 'expired' + ) { + active.current.tab.close() + active.current = null + setSetupId(undefined) + setError( + query.error?.message ?? + (result?.status === 'failed' + ? result.error + : 'This GitHub connection attempt expired. Try connecting again.') + ) + void cancelSetup({ organizationId, setupId }).catch(() => undefined) + } + }, [query.data, query.error, setupId, organizationId, client, cancelSetup]) + + const connect = useCallback( + async (intent?: StartGitHubSearchSetupBody['intent']) => { + if (!organizationId) return + if (active.current) { + active.current.tab.focus() + return + } + const tab = window.open('about:blank', '_blank', 'width=600,height=700') + if (!tab) { + setError('Allow pop-ups for this site to connect GitHub, then try again.') + return + } + tab.opener = null + const id = generateId() + active.current = { setupId: id, tab } + setError(null) + try { + const result = await start({ organizationId, setupId: id, ...(intent ? { intent } : {}) }) + if (active.current?.setupId !== id) return + const url = resolveGitHubSetupUrl(result.url, window.location.origin) + setSetupId(id) + tab.location.href = url + } catch (failure) { + if (active.current?.setupId !== id) return + tab.close() + active.current = null + setSetupId(undefined) + setError(getErrorMessage(failure, 'Could not connect GitHub')) + void cancelSetup({ organizationId, setupId: id }).catch(() => undefined) + } + }, + [organizationId, start, cancelSetup] + ) + + const cancel = useCallback(() => { + const attempt = active.current + if (!attempt || !organizationId) return + active.current = null + attempt.tab.close() + setSetupId(undefined) + setError(null) + void cancelSetup({ organizationId, setupId: attempt.setupId }).catch(() => undefined) + }, [organizationId, cancelSetup]) + + return { connect, cancel, pending: isStarting || Boolean(setupId), error } +} diff --git a/apps/sim/hooks/use-personal-source-account.test.tsx b/apps/sim/hooks/use-personal-source-account.test.tsx new file mode 100644 index 00000000000..cccfe0e977f --- /dev/null +++ b/apps/sim/hooks/use-personal-source-account.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + refetch: vi.fn(), + error: vi.fn(), + completed: null as string | null, + cache: vi.fn(), + connected: vi.fn(), +})) +vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ setQueryData: mocks.cache }) })) +vi.mock('@sim/emcn', () => ({ toast: { error: mocks.error } })) +vi.mock('@/hooks/queries/personal-source-setup', () => ({ + personalSourceSetupKeys: { list: (query: unknown) => ['personal-source-setup', query] }, + useAuthorizePersonalSourceSetup: () => ({ mutateAsync: mocks.authorize, isPending: false }), + usePersonalSourceSetupAccounts: () => ({ + data: { accounts: [], completedCredentialId: mocks.completed }, + refetch: mocks.refetch, + }), +})) + +import { usePersonalSourceAccount } from '@/hooks/use-personal-source-account' + +describe('personal source account authorization', () => { + let root: Root + let container: HTMLDivElement + let current: ReturnType + let channels: Array<{ + onmessage: ((event: MessageEvent) => void) | null + close: ReturnType + }> + let tab: { + opener: unknown + location: { href: string } + focus: ReturnType + close: ReturnType + } + function Probe() { + current = usePersonalSourceAccount({ + organizationId: 'org-1', + connectorType: 'jira', + onConnected: mocks.connected, + }) + return null + } + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mocks.completed = null + mocks.authorize.mockResolvedValue({ + kind: 'authorization', + url: 'https://auth.atlassian.com/authorize', + }) + channels = [] + vi.stubGlobal( + 'BroadcastChannel', + class { + onmessage = null + close = vi.fn() + constructor() { + channels.push(this) + } + } + ) + tab = { opener: {}, location: { href: 'about:blank' }, focus: vi.fn(), close: vi.fn() } + vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root.render()) + }) + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + it('authorizes before a source exists and requires server confirmation of completion', async () => { + await act(async () => current.connect()) + expect(tab.opener).toBeNull() + expect(tab.location.href).toBe('https://auth.atlassian.com/authorize') + expect(mocks.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'authorize', + organizationId: 'org-1', + connectorType: 'jira', + oauthCompletionId: expect.any(String), + }) + ) + expect(current.pending).toBe(true) + act(() => channels[0].onmessage?.({ data: 'connected' } as MessageEvent)) + expect(mocks.refetch).toHaveBeenCalledOnce() + expect(current.pending).toBe(true) + expect(tab.close).not.toHaveBeenCalled() + mocks.completed = 'my-account' + act(() => root.render()) + expect(current.pending).toBe(false) + expect(tab.close).toHaveBeenCalledOnce() + expect(mocks.connected).toHaveBeenCalledWith('my-account') + mocks.completed = null + act(() => root.render()) + expect(current.pending).toBe(false) + }) + it('shows an account mismatch as a toast and allows a fresh attempt', async () => { + await act(async () => current.connect()) + act(() => channels[0].onmessage?.({ data: 'account_mismatch' } as MessageEvent)) + expect(mocks.error).toHaveBeenCalledWith('Choose the account matching your Sim email address.') + expect(current.pending).toBe(false) + await act(async () => current.connect()) + expect(mocks.authorize).toHaveBeenCalledTimes(2) + }) + it('ignores a late authorization response after cancellation', async () => { + let resolve!: (value: { kind: string; url: string }) => void + mocks.authorize.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + let connecting!: Promise + act(() => { + connecting = current.connect() + }) + act(() => current.cancel()) + await act(async () => { + resolve({ kind: 'authorization', url: 'https://auth.atlassian.com/authorize' }) + await connecting + }) + expect(tab.location.href).toBe('about:blank') + expect(current.pending).toBe(false) + }) + it('times out pending authorization and closes its channel on unmount', async () => { + await act(async () => current.connect()) + act(() => vi.advanceTimersByTime(10 * 60_000)) + expect(current.pending).toBe(false) + expect(mocks.error).toHaveBeenCalledWith( + 'This connection attempt expired. Try connecting your account again.' + ) + expect(channels[0].close).toHaveBeenCalledOnce() + }) + it('reports popup blocking without starting enrollment', async () => { + vi.mocked(window.open).mockReturnValue(null) + await act(async () => current.connect()) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalledWith('Allow pop-ups for this site to connect your account.') + }) +}) diff --git a/apps/sim/hooks/use-personal-source-account.ts b/apps/sim/hooks/use-personal-source-account.ts new file mode 100644 index 00000000000..3ac8447127d --- /dev/null +++ b/apps/sim/hooks/use-personal-source-account.ts @@ -0,0 +1,135 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { useQueryClient } from '@tanstack/react-query' +import type { PersonalSourceSetupQuery } from '@/lib/api/contracts/knowledge/personal-source-setup' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + credentialGroupOAuthCompletionChannel, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' +import { + personalSourceSetupKeys, + useAuthorizePersonalSourceSetup, + usePersonalSourceSetupAccounts, +} from '@/hooks/queries/personal-source-setup' + +interface PersonalSourceAccountProps { + organizationId: string + connectorType: PersonalSourceSetupQuery['connectorType'] + onConnected?: (credentialId: string) => void +} + +/** A popup message only refreshes the account inventory; the server proves completion. */ +export function usePersonalSourceAccount({ + organizationId, + connectorType, + onConnected, +}: PersonalSourceAccountProps) { + const [completionId, setCompletionId] = useState() + const attempt = useRef<{ id: string; tab: Window } | null>(null) + const accounts = usePersonalSourceSetupAccounts({ organizationId, connectorType, completionId }) + const { mutateAsync: authorize } = useAuthorizePersonalSourceSetup() + const queryClient = useQueryClient() + const onConnectedRef = useRef(onConnected) + const completed = accounts.data?.completedCredentialId + const { refetch } = accounts + + useEffect(() => { + onConnectedRef.current = onConnected + }, [onConnected]) + + useEffect(() => { + return () => { + attempt.current?.tab.close() + attempt.current = null + } + }, []) + + useEffect(() => { + if (!completionId || completed) return + const fail = (message: string) => { + if (attempt.current?.id !== completionId) return + attempt.current.tab.close() + attempt.current = null + setCompletionId(undefined) + toast.error(message) + } + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) + channel.onmessage = ({ data }: MessageEvent) => { + if (isCredentialGroupOAuthFailure(data)) fail(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[data]) + else if (data === 'connected') void refetch() + } + const timer = window.setTimeout( + () => fail(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.expired), + 10 * 60_000 + ) + return () => { + channel.close() + window.clearTimeout(timer) + } + }, [completionId, completed, refetch]) + + useEffect(() => { + if (completed && attempt.current && attempt.current.id === completionId) { + attempt.current.tab.close() + attempt.current = null + setCompletionId(undefined) + queryClient.setQueryData(personalSourceSetupKeys.list({ organizationId, connectorType }), { + ...accounts.data, + completedCredentialId: null, + }) + onConnectedRef.current?.(completed) + } + }, [completed, completionId, accounts.data, queryClient, organizationId, connectorType]) + + const connect = useCallback(async () => { + if (attempt.current) { + attempt.current.tab.focus() + return + } + const tab = window.open('about:blank', '_blank', 'width=600,height=700') + if (!tab) { + toast.error('Allow pop-ups for this site to connect your account.') + return + } + tab.opener = null + const id = generateId() + attempt.current = { id, tab } + setCompletionId(id) + try { + const result = await authorize({ + action: 'authorize', + organizationId, + connectorType, + oauthCompletionId: id, + }) + if (attempt.current?.id !== id) return + const url = new URL(result.url) + if ( + url.protocol !== 'https:' && + !(url.protocol === 'http:' && url.origin === window.location.origin) + ) { + throw new Error('The provider authorization URL is invalid') + } + tab.location.href = url.href + } catch (error) { + if (attempt.current?.id !== id) return + tab.close() + attempt.current = null + setCompletionId(undefined) + toast.error(getErrorMessage(error, 'Could not connect your account')) + } + }, [authorize, organizationId, connectorType]) + + const cancel = useCallback(() => { + attempt.current?.tab.close() + attempt.current = null + setCompletionId(undefined) + }, []) + + return { accounts, connect, cancel, pending: Boolean(completionId) } +} diff --git a/apps/sim/hooks/use-search-integration-connection.test.tsx b/apps/sim/hooks/use-search-integration-connection.test.tsx index 421180cab62..504e94bd0e9 100644 --- a/apps/sim/hooks/use-search-integration-connection.test.tsx +++ b/apps/sim/hooks/use-search-integration-connection.test.tsx @@ -134,6 +134,20 @@ afterEach(() => { }) describe('Search connection card lifecycle', () => { + it('confirms account-first setup against inventory and reflects later revocation', () => { + m.requestedTarget = { type: 'link', provider: 'jira', connectorType: 'jira' } + render() + act(() => connection().completeSetup({ connectorId: 'new-source', credentialId: 'mine' })) + expect(connection().connectorId).toBe('new-source') + expect(connection().connected).toBe(false) + expect(m.mutate).not.toHaveBeenCalled() + m.accounts = [{ credentialId: 'mine', status: 'connected' }] + render() + expect(connection().connected).toBe(true) + m.accounts = [{ credentialId: 'mine', status: 'reconnect_needed' }] + render() + expect(connection().connected).toBe(false) + }) it('starts OAuth only on click and completes only when its receipt and current account agree', async () => { render() expect(m.mutate).not.toHaveBeenCalled() diff --git a/apps/sim/hooks/use-search-integration-connection.ts b/apps/sim/hooks/use-search-integration-connection.ts index 96c455ac16f..3f0cd7873cd 100644 --- a/apps/sim/hooks/use-search-integration-connection.ts +++ b/apps/sim/hooks/use-search-integration-connection.ts @@ -213,7 +213,21 @@ export function useSearchIntegrationConnection({ error: 'Connection canceled. You can try again.', }) }, [attempt, key]) + const completeSetup = useCallback( + (result: { connectorId: string; credentialId: string }) => { + writeSearchConnectionAttempt(key, { + completionId: generateId(), + requestedAt: Date.now(), + connectorId: result.connectorId, + credentialId: result.credentialId, + status: 'connected', + error: null, + }) + }, + [key] + ) return { + completeSetup, connect, cancel, inventoryError: query.error?.message, diff --git a/apps/sim/hooks/use-settled-terminal-commands.ts b/apps/sim/hooks/use-settled-terminal-commands.ts new file mode 100644 index 00000000000..e6be82b99cd --- /dev/null +++ b/apps/sim/hooks/use-settled-terminal-commands.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react' +import type { TerminalTabState } from '@sim/terminal-protocol' + +/** + * How long a command must run before the tab names it. + * + * A tab that says what it is busy with is useful for a build you left running + * in the background, and pure noise for `ls` — swapping the label and spinning + * the icon for thirty milliseconds reads as a glitch. Waiting a beat keeps the + * signal and drops the flicker. + */ +const COMMAND_SETTLE_MS = 1_000 + +function sameIds(a: ReadonlySet, b: ReadonlySet): boolean { + return a.size === b.size && [...a].every((id) => b.has(id)) +} + +/** + * The terminals whose command has been running long enough to show. Returns a + * stable set, so a tab strip that would render identically does not re-render. + */ +export function useSettledTerminalCommands(tabs: readonly TerminalTabState[]): ReadonlySet { + const [settled, setSettled] = useState>(() => new Set()) + const startedAt = useRef | null>(null) + startedAt.current ??= new Map() + + useEffect(() => { + const started = startedAt.current + if (!started) return + const live = new Set(tabs.map((tab) => tab.terminalId)) + for (const id of [...started.keys()]) { + if (!live.has(id)) started.delete(id) + } + for (const tab of tabs) { + if (!tab.running) started.delete(tab.terminalId) + else if (!started.has(tab.terminalId)) started.set(tab.terminalId, Date.now()) + } + + const recompute = () => { + const now = Date.now() + const next = new Set() + let soonest = Number.POSITIVE_INFINITY + for (const [id, at] of started) { + const elapsed = now - at + if (elapsed >= COMMAND_SETTLE_MS) next.add(id) + else soonest = Math.min(soonest, COMMAND_SETTLE_MS - elapsed) + } + setSettled((current) => (sameIds(current, next) ? current : next)) + return soonest + } + + const soonest = recompute() + if (!Number.isFinite(soonest)) return + const timer = setTimeout(recompute, Math.max(0, soonest)) + return () => clearTimeout(timer) + }, [tabs]) + + return settled +} diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts index f1a1d8986c8..cf7e3bf90fd 100644 --- a/apps/sim/hooks/use-speech-to-text.ts +++ b/apps/sim/hooks/use-speech-to-text.ts @@ -50,6 +50,8 @@ interface UseSpeechToTextProps { onError?: (error: SpeechToTextError) => void /** Attributes the voice-input cost to this workspace for per-member usage. */ workspaceId?: string + /** Attributes organization chat and search voice input to the organization. */ + organizationId?: string } interface UseSpeechToTextReturn { @@ -89,6 +91,7 @@ export function useSpeechToText({ onUsageLimitExceeded, onError, workspaceId, + organizationId, }: UseSpeechToTextProps): UseSpeechToTextReturn { const [isListening, setIsListening] = useState(false) /** @@ -108,6 +111,7 @@ export function useSpeechToText({ const onUsageLimitExceededRef = useRef(onUsageLimitExceeded) const onErrorRef = useRef(onError) const workspaceIdRef = useRef(workspaceId) + const organizationIdRef = useRef(organizationId) const mountedRef = useRef(true) const startingRef = useRef(false) @@ -128,6 +132,7 @@ export function useSpeechToText({ onUsageLimitExceededRef.current = onUsageLimitExceeded onErrorRef.current = onError workspaceIdRef.current = workspaceId + organizationIdRef.current = organizationId const flushAudioBuffer = useCallback(() => { const ws = wsRef.current @@ -213,7 +218,9 @@ export function useSpeechToText({ let tokenData: Awaited>> try { tokenData = await requestJson(speechTokenContract, { - body: workspaceIdRef.current ? { workspaceId: workspaceIdRef.current } : {}, + body: organizationIdRef.current + ? { organizationId: organizationIdRef.current } + : { workspaceId: workspaceIdRef.current }, }) } catch (err) { if (isApiClientError(err) && err.status === 402) { diff --git a/apps/sim/hooks/use-voice-input.test.tsx b/apps/sim/hooks/use-voice-input.test.tsx new file mode 100644 index 00000000000..f3b40a6528b --- /dev/null +++ b/apps/sim/hooks/use-voice-input.test.tsx @@ -0,0 +1,144 @@ +/** @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 type { useSpeechToText } from '@/hooks/use-speech-to-text' + +const mocks = vi.hoisted(() => ({ speech: vi.fn() })) +vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech })) + +import { useVoiceInput } from '@/hooks/use-voice-input' + +let root: Root +let container: HTMLDivElement +let value: string +let voice: ReturnType + +function Harness() { + voice = useVoiceInput({ + organizationId: 'organization-a', + getValue: () => value, + onChange: (next) => { + value = next + }, + }) + return null +} + +function transcript(text: string) { + act(() => mocks.speech.mock.calls.at(-1)![0].onTranscript(text)) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.speech.mockReturnValue({ + isListening: false, + isSupported: true, + audioLevelsRef: { current: new Float32Array(5) }, + toggleListening: vi.fn(), + resetTranscript: vi.fn(), + }) + value = 'Find' + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root.render()) + act(() => voice.toggleListening()) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('voice input manual edits', () => { + it('preserves edits made before the first transcript arrives', () => { + value = 'Summarize' + transcript('the report') + expect(value).toBe('Summarize the report') + }) + + it('preserves a changed prefix while updating partial speech', () => { + transcript('the repor') + value = 'Summarize the repor' + transcript('the report') + expect(value).toBe('Summarize the report') + transcript('the report from June') + expect(value).toBe('Summarize the report from June') + }) + + it.each(['Replacement draft', ''])( + 'preserves a replaced or cleared draft (%s)', + (replacement) => { + transcript('the report') + value = replacement + transcript('the report') + expect(value).toBe(replacement) + transcript('the report from June') + expect(value).toBe(replacement ? `${replacement} from June` : 'from June') + } + ) + + it('preserves corrections inside dictated text across later partial updates', () => { + transcript('the red report') + value = 'Find the blue report' + transcript('the red report from June') + expect(value).toBe('Find the blue report from June') + transcript('the red report from July') + expect(value).toBe('Find the blue report from July') + }) + + it('keeps manual suffixes after speech revisions and before new speech', () => { + transcript('the red report') + value += ' and notes' + transcript('the blue report') + expect(value).toBe('Find the blue report and notes') + transcript('the blue report from June') + expect(value).toBe('Find the blue report and notes from June') + }) + + it('prefers the manual correction when speech revises the same word', () => { + value = '' + act(() => voice.toggleListening()) + transcript('hel') + value = 'help' + transcript('hello') + expect(value).toBe('help') + transcript('hello again') + expect(value).toBe('help again') + }) + + it('keeps appended speech when another revision conflicts with a manual correction', () => { + transcript('the red report') + value = 'Find the blue report' + transcript('the green report from June') + expect(value).toBe('Find the blue report from June') + transcript('the green report from June and July') + expect(value).toBe('Find the blue report from June and July') + }) + + it('preserves separate manual edits around an independently revised word', () => { + transcript('the red report') + value = 'Summarize the red report and notes' + transcript('the green report from June') + expect(value).toBe('Summarize the green report and notes from June') + }) + + it('starts fresh after the draft and transcript are cleared on submit', () => { + transcript('the report') + value = '' + act(() => voice.resetTranscript()) + transcript('Next question') + expect(value).toBe('Next question') + }) + + it('continues dictation after a large draft is manually replaced', () => { + const longTranscript = Array.from({ length: 300 }, (_, index) => `word${index}`).join(' ') + transcript(longTranscript) + value = 'New draft' + transcript(`${longTranscript} next question`) + expect(value).toBe('New draft next question') + }) +}) diff --git a/apps/sim/hooks/use-voice-input.ts b/apps/sim/hooks/use-voice-input.ts new file mode 100644 index 00000000000..694490d45db --- /dev/null +++ b/apps/sim/hooks/use-voice-input.ts @@ -0,0 +1,160 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import { toast } from '@sim/emcn' +import { diffWordsWithSpace } from 'diff' +import { getDesktopBridge } from '@/lib/desktop' +import { type SpeechToTextError, useSpeechToText } from '@/hooks/use-speech-to-text' + +interface UseVoiceInputProps { + workspaceId?: string + organizationId?: string + getValue: () => string + onChange: (value: string) => void + onUsageLimitExceeded?: (message?: string, isMemberLimit?: boolean) => void +} + +interface TextEdit { + from: number + to: number + insert: string +} + +function textChanges(before: string, after: string): TextEdit[] { + const parts = diffWordsWithSpace(before, after, { maxEditLength: 256 }) + if (!parts) return [{ from: 0, to: before.length, insert: after }] + const edits: TextEdit[] = [] + let position = 0 + let edit: TextEdit | undefined + for (const part of parts) { + if (part.added || part.removed) { + edit ??= { from: position, to: position, insert: '' } + if (part.added) edit.insert += part.value + if (part.removed) { + position += part.value.length + edit.to = position + } + } else { + if (edit) edits.push(edit) + edit = undefined + position += part.value.length + } + } + if (edit) edits.push(edit) + return edits +} + +/** Rebase independent speech updates around manual edits; the user's text wins conflicts. */ +function mergeTranscript(previous: string, next: string, current: string): string { + if (current === previous) return next + if (next === previous) return current + + const speechEdits = textChanges(previous, next) + const manualEdits = textChanges(previous, current) + let result = current + for (const speech of speechEdits.reverse()) { + let offset = 0 + let conflict = false + for (const edit of manualEdits) { + if (speech.to <= edit.from && speech.from < edit.from) continue + if (speech.from >= edit.to && (speech.from > edit.from || /^\s/.test(speech.insert))) { + offset += edit.insert.length - (edit.to - edit.from) + } else { + conflict = true + break + } + } + if (!conflict) { + result = + result.slice(0, speech.from + offset) + speech.insert + result.slice(speech.to + offset) + } + } + return current ? result : result.trimStart() +} + +/** Shares draft appending and microphone recovery across chat and search inputs. */ +export function useVoiceInput({ + workspaceId, + organizationId, + getValue, + onChange, + onUsageLimitExceeded = (message) => toast.error(message || 'You are out of credits.'), +}: UseVoiceInputProps) { + const prefixRef = useRef('') + const previousTranscriptValueRef = useRef('') + const getValueRef = useRef(getValue) + getValueRef.current = getValue + const [permissionHelpOpen, setPermissionHelpOpen] = useState(false) + + function handleSpeechError(error: SpeechToTextError) { + if (error === 'microphone-blocked') { + const desktopBridge = getDesktopBridge() + if (desktopBridge) { + const { openMicrophoneSettings } = desktopBridge + toast.error( + 'Microphone access is blocked. Allow Sim to use the microphone in your system privacy settings.', + openMicrophoneSettings + ? { + action: { + label: 'Open Settings', + onClick: () => void openMicrophoneSettings(), + }, + } + : undefined + ) + } else { + toast.error('Microphone access is blocked. Allow it for this site and try again.', { + action: { + label: 'Show steps', + onClick: () => setPermissionHelpOpen(true), + }, + }) + } + return + } + if (error === 'microphone-unavailable') { + toast.error('No microphone found. Connect one and try again.') + return + } + toast.error('Could not start voice input. Try again.') + } + + const { + toggleListening: rawToggle, + resetTranscript: rawReset, + ...speech + } = useSpeechToText({ + workspaceId, + organizationId, + onTranscript: (text) => { + const next = prefixRef.current ? `${prefixRef.current} ${text}` : text + const value = mergeTranscript(previousTranscriptValueRef.current, next, getValueRef.current()) + previousTranscriptValueRef.current = next + onChange(value) + }, + onUsageLimitExceeded, + onError: handleSpeechError, + }) + + const toggleListening = useCallback(() => { + if (!speech.isListening) { + prefixRef.current = getValueRef.current() + previousTranscriptValueRef.current = prefixRef.current + } + rawToggle() + }, [speech.isListening, rawToggle]) + + const resetTranscript = useCallback(() => { + prefixRef.current = '' + previousTranscriptValueRef.current = '' + rawReset() + }, [rawReset]) + + return { + ...speech, + toggleListening, + resetTranscript, + permissionHelpOpen, + setPermissionHelpOpen, + } +} diff --git a/apps/sim/lib/api-key/service.test.ts b/apps/sim/lib/api-key/service.test.ts index 898b4f5ea9e..0c78eca1391 100644 --- a/apps/sim/lib/api-key/service.test.ts +++ b/apps/sim/lib/api-key/service.test.ts @@ -31,6 +31,7 @@ vi.mock('@sim/logger', () => ({ logger: serviceLogger, runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), })) const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({ diff --git a/apps/sim/lib/api/client-info.ts b/apps/sim/lib/api/client-info.ts new file mode 100644 index 00000000000..419b9e989bc --- /dev/null +++ b/apps/sim/lib/api/client-info.ts @@ -0,0 +1,36 @@ +import { CLIENT_INFO_HEADER, formatClientInfo } from '@sim/utils/client-info' +import { getDesktopShellVersion } from '@/lib/desktop' + +export { CLIENT_INFO_HEADER } + +export interface BrowserSurface { + surface: 'web' | 'desktop' + /** The desktop shell's version; absent on the web, which has no version of its own. */ + version?: string +} + +/** + * Which browser-hosted surface this page is: the desktop shell when its + * preload bridge is present, the web app otherwise. The one decision behind + * both the `X-Sim-Client-Info` header and the PostHog super property, so the + * two can never disagree. + */ +export function resolveBrowserSurface(): BrowserSurface { + const shellVersion = getDesktopShellVersion() + return shellVersion === undefined + ? { surface: 'web' } + : { surface: 'desktop', version: shellVersion } +} + +/** + * The `X-Sim-Client-Info` value the web app sends on its own API calls. + * + * Returns `undefined` on the server, where the same client code runs during + * prefetching and a request from the app to itself is not a web-surface call. + * Inside the desktop shell the main process stamps the same identity on every + * app-origin request as a backstop for traffic the page never issues itself. + */ +export function getClientInfoHeader(): string | undefined { + if (typeof window === 'undefined') return undefined + return formatClientInfo(resolveBrowserSurface()) +} diff --git a/apps/sim/lib/api/client/request.test.ts b/apps/sim/lib/api/client/request.test.ts index 4a9453deb51..3a059ed61bb 100644 --- a/apps/sim/lib/api/client/request.test.ts +++ b/apps/sim/lib/api/client/request.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { requestJson } from '@/lib/api/client/request' import { CLIENT_ID_HEADER } from '@/lib/api/client-id' +import { CLIENT_INFO_HEADER } from '@/lib/api/client-info' import { listKnowledgeDocumentsContract } from '@/lib/api/contracts/knowledge' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -123,3 +124,33 @@ describe('requestJson client id header', () => { expect(sentHeaders(fetchMock)[CLIENT_ID_HEADER]).toBeUndefined() }) }) + +describe('requestJson client info header', () => { + const contract = defineRouteContract({ + method: 'GET', + path: '/api/test', + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, + }) + + function sentHeaders(fetchMock: ReturnType): Record { + return (fetchMock.mock.calls[0][1] as RequestInit).headers as Record + } + + it('declares the web surface in the browser', async () => { + vi.stubGlobal('window', {}) + const fetchMock = mockFetchReturning({ ok: true }) + + await requestJson(contract, {}) + + expect(sentHeaders(fetchMock)[CLIENT_INFO_HEADER]).toBe('web') + }) + + it('omits it on the server, where a request to itself is not a web-surface call', async () => { + vi.stubGlobal('window', undefined) + const fetchMock = mockFetchReturning({ ok: true }) + + await requestJson(contract, {}) + + expect(sentHeaders(fetchMock)[CLIENT_INFO_HEADER]).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/api/client/request.ts b/apps/sim/lib/api/client/request.ts index cef9207cb16..039eba16d94 100644 --- a/apps/sim/lib/api/client/request.ts +++ b/apps/sim/lib/api/client/request.ts @@ -1,5 +1,6 @@ import { ApiClientError } from '@/lib/api/client/errors' import { CLIENT_ID_HEADER, getClientId } from '@/lib/api/client-id' +import { CLIENT_INFO_HEADER, getClientInfoHeader } from '@/lib/api/client-info' import type { AnyApiRouteContract, ApiSchema, @@ -109,6 +110,9 @@ function buildHeaders(headers: unknown, hasBody: boolean): Record)) { if (typeof value === 'string') output[key] = value diff --git a/apps/sim/lib/api/contracts/knowledge/connector-permissions.ts b/apps/sim/lib/api/contracts/knowledge/connector-permissions.ts new file mode 100644 index 00000000000..fd6fa65f17d --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/connector-permissions.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { + gitLabPermissionInputSchema, + gitLabPermissionSummarySchema, +} from '@/lib/api/contracts/knowledge/gitlab-permissions' + +export const connectorPermissionConfigSchema = z.discriminatedUnion('provider', [ + gitLabPermissionInputSchema, +]) +export const connectorPermissionSummarySchema = z.discriminatedUnion('provider', [ + gitLabPermissionSummarySchema, +]) +export type ConnectorPermissionConfigInput = z.input +export type ConnectorPermissionConfigData = z.output diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.test.ts b/apps/sim/lib/api/contracts/knowledge/connectors.test.ts index ed6870a25e9..5babbca4b6f 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.test.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + connectorDocumentDataSchema, connectorDocumentsQuerySchema, createConnectorBodySchema, updateConnectorAccessBodySchema, @@ -16,7 +17,7 @@ const base = { describe('connector document list contracts', () => { it('accepts each document set and trims filename search without interpreting wildcards', () => { - for (const filter of ['active', 'excluded', 'failed']) { + for (const filter of ['active', 'excluded', 'failed', 'skipped']) { expect( connectorDocumentsQuerySchema.parse({ filter, search: ' 50%_report ' }) ).toMatchObject({ @@ -26,6 +27,26 @@ describe('connector document list contracts', () => { } }) + it('adds skipped outcomes without requiring them from older document responses', () => { + const document = { + id: 'document', + filename: 'logo.png', + externalId: 'logo.png', + sourceUrl: null, + enabled: true, + userExcluded: false, + uploadedAt: '2026-01-01T00:00:00Z', + processingStatus: 'failed', + } + expect(connectorDocumentDataSchema.parse(document)).toMatchObject({ + processingStatus: 'failed', + processingOutcome: null, + }) + expect( + connectorDocumentDataSchema.parse({ ...document, processingOutcome: 'skipped' }) + ).toMatchObject({ processingStatus: 'failed', processingOutcome: 'skipped' }) + }) + it('preserves legacy flags when no document filter is supplied', () => { expect( connectorDocumentsQuerySchema.parse({ includeExcluded: 'true', failedOnly: 'false' }) diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index ce2185c981d..da71e989b1b 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + connectorPermissionConfigSchema, + connectorPermissionSummarySchema, +} from '@/lib/api/contracts/knowledge/connector-permissions' import { knowledgeBaseParamsSchema, knowledgeConnectorParamsSchema, @@ -43,10 +47,12 @@ export const createConnectorBodySchema = z.object({ connectorType: z.string().min(1), credentialId: z.string().min(1).optional(), apiKey: z.string().min(1).optional(), + permissionConfig: connectorPermissionConfigSchema.optional(), sourceConfig: z.record(z.string(), z.unknown()), syncIntervalMinutes: z.number().int().min(0).default(1440), accessMode: connectorRequestedAccessModeSchema.optional().default('workspace'), }) +export type CreateConnectorBody = z.input export const updateConnectorAccessBodySchema = z.object({ accessMode: connectorRequestedAccessModeSchema, @@ -56,17 +62,20 @@ export const updateConnectorAccessBodySchema = z.object({ export type UpdateConnectorAccessBody = z.input export const updateConnectorBodySchema = z.object({ + apiKey: z.string().min(1).max(4096).optional(), + permissionConfig: connectorPermissionConfigSchema.optional(), sourceConfig: z.record(z.string(), z.unknown()).optional(), syncIntervalMinutes: z.number().int().min(0).optional(), status: z.enum(['active', 'paused']).optional(), }) +export type UpdateConnectorBody = z.input export const deleteConnectorQuerySchema = z.object({ /** Also hard-delete the documents the connector produced; kept by default. */ deleteDocuments: booleanQueryFlagSchema.optional().default(false), }) -export const connectorDocumentFilterSchema = z.enum(['active', 'excluded', 'failed']) +export const connectorDocumentFilterSchema = z.enum(['active', 'excluded', 'failed', 'skipped']) export type ConnectorDocumentFilter = z.output export const connectorDocumentsQuerySchema = z.object({ @@ -110,6 +119,7 @@ export const connectorDataSchema = z id: z.string(), knowledgeBaseId: z.string(), connectorType: z.string(), + permissionConfig: connectorPermissionSummarySchema.optional(), credentialId: z.string().nullable(), sourceConfig: z.record(z.string(), z.unknown()), syncMode: z.string().nullable(), @@ -226,6 +236,8 @@ export const connectorDocumentDataSchema = z userExcluded: z.boolean(), uploadedAt: z.string(), processingStatus: z.string(), + processingOutcome: z.literal('skipped').nullable().default(null), + processingError: z.string().nullable().default(null), }) .passthrough() export type ConnectorDocumentData = z.output @@ -236,6 +248,7 @@ export const connectorDocumentsDataSchema = z.object({ active: z.number().int().nonnegative(), excluded: z.number().int().nonnegative(), failed: z.number().int().nonnegative().default(0), + skipped: z.number().int().nonnegative().default(0), }), hasMore: z.boolean().optional(), }) @@ -384,6 +397,12 @@ export const searchSourceCursorSchema = z.object({ export const listSearchSourcesQuerySchema = resourceOwnerSchema.safeExtend({ cursor: z.string().min(1).max(1024).optional(), connectorType: z.string().trim().min(1, 'connectorType cannot be empty').max(100).optional(), + excludeConnectorType: z + .string() + .trim() + .min(1, 'excludeConnectorType cannot be empty') + .max(100) + .optional(), search: z.string().trim().max(200).optional(), mine: booleanQueryFlagSchema.optional(), }) diff --git a/apps/sim/lib/api/contracts/knowledge/documents.test.ts b/apps/sim/lib/api/contracts/knowledge/documents.test.ts index 5c0510f5dc4..9a12967d460 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.test.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.test.ts @@ -2,12 +2,57 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { bulkCreateDocumentsBodySchema, + documentDataSchema, listKnowledgeDocumentsQuerySchema, parseDocumentTagFiltersParam, upsertDocumentBodySchema, } from '@/lib/api/contracts/knowledge/documents' +import { getDocumentIndexingStatus } from '@/lib/knowledge/documents/types' + +describe('document processing response compatibility', () => { + const document = { + id: 'document', + knowledgeBaseId: 'knowledge-base', + filename: 'logo.png', + fileUrl: '', + fileSize: 0, + mimeType: 'text/plain', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + processingStatus: 'failed', + enabled: true, + uploadedAt: '2026-01-01T00:00:00Z', + } + const previousSchema = documentDataSchema.omit({ processingOutcome: true }).extend({ + processingStatus: z.enum(['pending', 'processing', 'completed', 'failed']), + }) + + it('keeps skipped responses valid for the previous strict four-status reader', () => { + const response = documentDataSchema.parse({ ...document, processingOutcome: 'skipped' }) + expect(response.processingStatus).toBe('failed') + expect(getDocumentIndexingStatus(response)).toBe('skipped') + expect(previousSchema.parse(response)).toEqual(document) + }) + + it.each(['pending', 'processing', 'completed', 'failed'])( + 'accepts an older server’s %s response without inventing an outcome', + (processingStatus) => { + const response = documentDataSchema.parse({ ...document, processingStatus }) + expect(response.processingOutcome).toBeNull() + expect(getDocumentIndexingStatus(response)).toBe(processingStatus) + } + ) + + it('does not expand the stored-status wire enum to encode an indexing outcome', () => { + expect(documentDataSchema.safeParse({ ...document, processingStatus: 'skipped' }).success).toBe( + false + ) + }) +}) describe('listKnowledgeDocumentsQuerySchema.tagFilters', () => { it('keeps tagFilters a raw string (must NOT transform to an array)', () => { diff --git a/apps/sim/lib/api/contracts/knowledge/documents.ts b/apps/sim/lib/api/contracts/knowledge/documents.ts index 67dc7d8720f..a5219138b05 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.ts @@ -16,6 +16,7 @@ import { privateSecretProvenanceBundleSchema } from '@/lib/api/contracts/primiti import { defineRouteContract } from '@/lib/api/contracts/types' import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import { getFieldTypeForSlot, MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' +import { DOCUMENT_PROCESSING_STATUSES } from '@/lib/knowledge/documents/types' import { getOperatorsForFieldType, isValidFilterValue } from '@/lib/knowledge/filters/types' import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' @@ -257,7 +258,8 @@ export const documentDataSchema = z chunkCount: z.number(), tokenCount: z.number(), characterCount: z.number(), - processingStatus: z.enum(['pending', 'processing', 'completed', 'failed']), + processingStatus: z.enum(DOCUMENT_PROCESSING_STATUSES), + processingOutcome: z.literal('skipped').nullable().default(null), /** When indexing was last dispatched to a worker, which precedes a worker starting it. */ processingQueuedAt: nullableWireDateSchema.optional(), processingStartedAt: nullableWireDateSchema.optional(), diff --git a/apps/sim/lib/api/contracts/knowledge/github-setup.ts b/apps/sim/lib/api/contracts/knowledge/github-setup.ts new file mode 100644 index 00000000000..dfd0d3c16e0 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/github-setup.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { + githubInstallationIdSchema, + githubSearchInstallationSchema, +} from '@/lib/api/contracts/knowledge/github-installations' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const gitHubSearchSetupScopeSchema = z + .object({ + organizationId: organizationIdSchema, + setupId: z.string().uuid('GitHub setup ID must be a UUID'), + }) + .strict() +export type GitHubSearchSetupScope = z.input + +export const gitHubSearchSetupStatusSchema = z.discriminatedUnion('status', [ + z.object({ status: z.literal('pending') }), + z.object({ + status: z.literal('choosing'), + installations: z.array(githubSearchInstallationSchema).min(1).max(1000), + }), + z.object({ + status: z.literal('completed'), + credential: z.object({ + id: z.string().min(1).max(200), + displayName: z.string().min(1).max(500), + }), + }), + z.object({ status: z.literal('failed'), error: z.string().min(1).max(1000) }), + z.object({ status: z.literal('expired') }), +]) +export type GitHubSearchSetupStatus = z.output + +export const startGitHubSearchSetupResponseSchema = z.object({ + success: z.literal(true), + url: z.string().url().max(8192), +}) +export type StartGitHubSearchSetupResponse = z.output +export const startGitHubSearchSetupBodySchema = gitHubSearchSetupScopeSchema.extend({ + intent: z.literal('install').optional(), +}) +export type StartGitHubSearchSetupBody = z.input +export const startGitHubSearchSetupContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/github/setup', + body: startGitHubSearchSetupBodySchema, + response: { mode: 'json', schema: startGitHubSearchSetupResponseSchema }, +}) + +export const readGitHubSearchSetupResponseSchema = z.object({ + success: z.literal(true), + data: gitHubSearchSetupStatusSchema, +}) +export type ReadGitHubSearchSetupResponse = z.output +export const readGitHubSearchSetupContract = defineRouteContract({ + method: 'GET', + path: '/api/knowledge/github/setup', + query: gitHubSearchSetupScopeSchema, + response: { mode: 'json', schema: readGitHubSearchSetupResponseSchema }, +}) + +export const cancelGitHubSearchSetupResponseSchema = z.object({ success: z.literal(true) }) +export type CancelGitHubSearchSetupResponse = z.output +export const cancelGitHubSearchSetupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/knowledge/github/setup', + body: gitHubSearchSetupScopeSchema, + response: { mode: 'json', schema: cancelGitHubSearchSetupResponseSchema }, +}) + +export const selectGitHubSearchSetupBodySchema = gitHubSearchSetupScopeSchema.extend({ + action: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('select'), installationId: githubInstallationIdSchema }).strict(), + z.object({ kind: z.literal('install') }).strict(), + ]), +}) +export type SelectGitHubSearchSetupBody = z.input +export const selectGitHubSearchSetupContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/github/setup/selection', + body: selectGitHubSearchSetupBodySchema, + response: { mode: 'json', schema: startGitHubSearchSetupResponseSchema }, +}) + +export const continueGitHubSearchSetupQuerySchema = gitHubSearchSetupScopeSchema.extend({ + oauth: z.string().min(1).max(100).optional(), +}) +export type ContinueGitHubSearchSetupQuery = z.input +export const continueGitHubSearchSetupContract = defineRouteContract({ + method: 'GET', + path: '/api/knowledge/github/setup/continue', + query: continueGitHubSearchSetupQuerySchema, + response: { mode: 'redirect' }, +}) + +export const completeGitHubSearchSetupQuerySchema = z.object({ + state: z.string().uuid('GitHub setup state must be a UUID'), + installation_id: githubInstallationIdSchema.optional(), + setup_action: z.string().min(1).max(40).optional(), +}) +export type CompleteGitHubSearchSetupQuery = z.input +export const completeGitHubSearchSetupContract = defineRouteContract({ + method: 'GET', + path: '/api/knowledge/github/setup/callback', + query: completeGitHubSearchSetupQuerySchema, + response: { mode: 'redirect' }, +}) diff --git a/apps/sim/lib/api/contracts/knowledge/gitlab-permissions.ts b/apps/sim/lib/api/contracts/knowledge/gitlab-permissions.ts new file mode 100644 index 00000000000..f91d6f6aaa6 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/gitlab-permissions.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { + GITLAB_CSV_MAX_BYTES, + GITLAB_PERMISSION_MODES, +} from '@/connectors/gitlab/permission-config/types' + +const gitLabCsvUploadSchema = z + .object({ + filename: z.string().trim().min(1).max(255), + content: z.string().min(1).max(GITLAB_CSV_MAX_BYTES), + }) + .strict() +export const gitLabPermissionInputSchema = z + .object({ + provider: z.literal('gitlab'), + mode: z.enum(GITLAB_PERMISSION_MODES), + expectedRevision: z.number().int().nonnegative().optional(), + userMapping: gitLabCsvUploadSchema.optional(), + projectPermissions: gitLabCsvUploadSchema.optional(), + }) + .strict() +export type GitLabPermissionUploadInput = z.input +const gitLabCsvFileSummarySchema = z.object({ + filename: z.string().min(1).max(255), + uploadedAt: z.string().datetime(), + rowCount: z.number().int().nonnegative().max(100_000), +}) +export const gitLabPermissionSummarySchema = z.object({ + provider: z.literal('gitlab'), + mode: z.enum(GITLAB_PERMISSION_MODES), + revision: z.number().int().nonnegative(), + userMapping: gitLabCsvFileSummarySchema.nullable(), + projectPermissions: gitLabCsvFileSummarySchema.nullable(), +}) +export type GitLabPermissionData = z.output diff --git a/apps/sim/lib/api/contracts/knowledge/personal-source-setup.test.ts b/apps/sim/lib/api/contracts/knowledge/personal-source-setup.test.ts new file mode 100644 index 00000000000..58e8fa013b5 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/personal-source-setup.test.ts @@ -0,0 +1,47 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { personalSourceSetupBodySchema } from '@/lib/api/contracts/knowledge/personal-source-setup' +import { executeSelectorBodySchema } from '@/lib/api/contracts/selectors/execute' + +const source = { + action: 'connect', + organizationId: 'organization-1', + connectorType: 'jira', + credentialId: 'account-1', + domain: 'example.atlassian.net', + keys: ['PROJECT'], +} + +describe('personal Search setup contracts', () => { + it('accepts a bounded bulk selection larger than the legacy joined-string limit', () => { + const keys = Array.from({ length: 1000 }, (_, index) => `PROJECT${index}`) + expect(personalSourceSetupBodySchema.parse({ ...source, keys })).toMatchObject({ keys }) + }) + it.each([[], Array.from({ length: 1001 }, (_, index) => `P${index}`), [''], ['x'.repeat(256)]])( + 'rejects empty, oversized, or unbounded selections %#', + (keys) => { + expect(personalSourceSetupBodySchema.safeParse({ ...source, keys }).success).toBe(false) + } + ) + it('rejects arbitrary credential kinds, scope overrides and provider keys', () => { + for (const extra of [ + { workspaceId: 'workspace-1' }, + { selectorKey: 'jira.issues' }, + { accessMode: 'admin' }, + { connectorType: 'slack' }, + ]) { + expect(personalSourceSetupBodySchema.safeParse({ ...source, ...extra }).success).toBe(false) + } + }) + it('does not expose the trusted personal browsing marker through the generic selector API', () => { + expect( + executeSelectorBodySchema.safeParse({ + selectorKey: 'jira.projectKeys', + scope: { kind: 'organization', organizationId: 'organization-1' }, + context: { oauthCredential: 'account-1', domain: 'example.atlassian.net' }, + request: { kind: 'list' }, + personalSearchSetup: 'jira', + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/personal-source-setup.ts b/apps/sim/lib/api/contracts/knowledge/personal-source-setup.ts new file mode 100644 index 00000000000..c3e64253078 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/personal-source-setup.ts @@ -0,0 +1,86 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { successResponseSchema } from '@/lib/api/contracts/knowledge/shared' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { + executeSelectorResponseSchema, + selectorRequestSchema, +} from '@/lib/api/contracts/selectors/execute' +import { MAX_PERSONAL_SOURCE_SETUP_KEYS } from '@/lib/sim-search/personal-source-setup' + +const setupOwnerSchema = z.object({ + organizationId: organizationIdSchema, + connectorType: z.enum(['jira', 'confluence']), +}) +const setupCredentialSchema = setupOwnerSchema.extend({ + credentialId: z.string().min(1).max(128), + domain: z.string().trim().min(1, 'Enter your Atlassian site').max(253), +}) + +export const personalSourceSetupQuerySchema = setupOwnerSchema.extend({ + completionId: z.string().uuid().optional(), +}) +export type PersonalSourceSetupQuery = z.input + +export const personalSourceSetupAccountsSchema = z.object({ + accounts: z + .array( + z.object({ + id: z.string().min(1).max(128), + name: z.string().max(512), + provider: z.enum(['jira', 'confluence']), + type: z.literal('managed_oauth'), + scopes: z.array(z.string().max(200)).max(200), + }) + ) + .max(1000), + completedCredentialId: z.string().min(1).max(128).nullable(), +}) +export type PersonalSourceSetupAccounts = z.output + +export const personalSourceSetupBodySchema = z.discriminatedUnion('action', [ + setupOwnerSchema + .extend({ action: z.literal('authorize'), oauthCompletionId: z.string().uuid() }) + .strict(), + setupCredentialSchema + .extend({ + action: z.literal('connect'), + keys: z + .array(z.string().trim().min(1).max(255)) + .min(1, 'Select at least one project or space') + .max( + MAX_PERSONAL_SOURCE_SETUP_KEYS, + 'Choose no more than 1,000 projects or spaces per source' + ), + }) + .strict(), + setupCredentialSchema + .extend({ action: z.literal('options'), request: selectorRequestSchema }) + .strict(), +]) +export type PersonalSourceSetupBody = z.input + +export const personalSourceSetupResultSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('authorization'), url: z.string().url() }), + z.object({ + kind: z.literal('connected'), + knowledgeBaseId: z.string().min(1).max(200), + connectorId: z.string().min(1).max(200), + }), + ...executeSelectorResponseSchema.options, +]) +export type PersonalSourceSetupResult = z.output + +export const listPersonalSourceSetupAccountsContract = defineRouteContract({ + method: 'GET', + path: '/api/knowledge/sim-search/personal-source-setup', + query: personalSourceSetupQuerySchema, + response: { mode: 'json', schema: successResponseSchema(personalSourceSetupAccountsSchema) }, +}) + +export const personalSourceSetupContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/sim-search/personal-source-setup', + body: personalSourceSetupBodySchema, + response: { mode: 'json', schema: successResponseSchema(personalSourceSetupResultSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/knowledge/search-stats.ts b/apps/sim/lib/api/contracts/knowledge/search-stats.ts new file mode 100644 index 00000000000..63f30e2ec8f --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/search-stats.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + getSearchStatsRangeError, + SEARCH_STATS_MAX_DAYS, + SEARCH_STATS_PEOPLE_LIMIT, + SEARCH_STATS_PERIODS, + SEARCH_STATS_SOURCE_LIMIT, + SEARCH_STATS_SURFACES, +} from '@/lib/knowledge/search/stats' + +export const organizationSearchStatsQuerySchema = z + .object({ + organizationId: organizationIdSchema, + period: z.enum(SEARCH_STATS_PERIODS).default('30d'), + surface: z.enum(SEARCH_STATS_SURFACES).optional(), + startDate: z.string().max(10).optional(), + endDate: z.string().max(10).optional(), + }) + .superRefine((query, context) => { + if (query.period === 'custom') { + const error = getSearchStatsRangeError(query) + if (error) context.addIssue({ code: 'custom', path: ['startDate'], message: error }) + } else if (query.startDate !== undefined || query.endDate !== undefined) { + context.addIssue({ + code: 'custom', + path: ['period'], + message: 'Choose Custom range to use start and end dates.', + }) + } + }) +export type OrganizationSearchStatsQuery = z.input + +const countSchema = z.number().int().nonnegative() +const sourceTypeSchema = z.string().min(1).max(100) +export const organizationSearchStatsSchema = z.object({ + start: z.string().datetime(), + end: z.string().datetime(), + totals: z.object({ + invocations: countSchema, + activePeople: countSchema, + results: countSchema, + }), + series: z + .array(z.object({ timestamp: z.string().datetime(), invocations: countSchema })) + .max(SEARCH_STATS_MAX_DAYS), + surfaces: z + .array(z.object({ surface: z.enum(SEARCH_STATS_SURFACES), invocations: countSchema })) + .max(7), + sources: z + .array(z.object({ sourceType: sourceTypeSchema, invocations: countSchema })) + .max(SEARCH_STATS_SOURCE_LIMIT), + people: z + .array( + z.object({ + userId: z.string().nullable(), + name: z.string().nullable(), + email: z.string().nullable(), + invocations: countSchema, + sourceTypes: z.array(sourceTypeSchema).max(SEARCH_STATS_SOURCE_LIMIT), + }) + ) + .max(SEARCH_STATS_PEOPLE_LIMIT), +}) +export type OrganizationSearchStats = z.output + +export const readOrganizationSearchStatsContract = defineRouteContract({ + method: 'GET', + path: '/api/knowledge/sim-search/stats', + query: organizationSearchStatsQuerySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true), data: organizationSearchStatsSchema }), + }, +}) diff --git a/apps/sim/lib/api/contracts/knowledge/slack.ts b/apps/sim/lib/api/contracts/knowledge/slack.ts index a40aa9370c9..f957b04eb3e 100644 --- a/apps/sim/lib/api/contracts/knowledge/slack.ts +++ b/apps/sim/lib/api/contracts/knowledge/slack.ts @@ -9,12 +9,14 @@ export const slackSearchInstallationSchema = z.object({ appId: z.string().min(1).max(200), teamId: z.string().min(1).max(200), teamName: z.string().min(1).max(200), + appKind: z.enum(['custom', 'shared']), enabled: z.boolean(), needsValidation: z.boolean(), lastOutcome: z.string().max(100).nullable(), lastEventAt: z.string().datetime().nullable(), }) export const listSlackSearchResponseSchema = z.object({ + sharedAppAvailable: z.boolean(), installations: z.array(slackSearchInstallationSchema).max(100), bots: z .array(z.object({ id: z.string().min(1).max(200), displayName: z.string().max(500) })) @@ -64,6 +66,7 @@ export const prepareSlackSearchContract = defineRouteContract({ response: { mode: 'json', schema: z.object({ + sharedAppId: z.string().min(1).max(200).nullable(), manifest: z.string().max(20_000), existingApp: z .object({ appId: z.string().min(1).max(200), teamId: z.string().min(1).max(200) }) @@ -74,6 +77,7 @@ export const prepareSlackSearchContract = defineRouteContract({ }) export const startSlackSearchOAuthBodySchema = prepareSlackSearchBodySchema.extend({ + mode: z.enum(['custom', 'shared']).default('custom'), installationId: z.string().min(1).max(200).optional(), clientId: z.string().trim().min(1).max(200).optional(), clientSecret: z.string().trim().min(1).max(500).optional(), diff --git a/apps/sim/lib/api/contracts/media/speech.ts b/apps/sim/lib/api/contracts/media/speech.ts index 79c2918ad6b..cfde9a08a64 100644 --- a/apps/sim/lib/api/contracts/media/speech.ts +++ b/apps/sim/lib/api/contracts/media/speech.ts @@ -1,16 +1,14 @@ import { z } from 'zod' +import { resourceOwnerSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -export const speechTokenBodySchema = z - .object({ - /** Workspace the session user is recording in. */ - workspaceId: z.string().optional(), - }) - .passthrough() +export const speechTokenBodySchema = resourceOwnerSchema +export type SpeechTokenBody = z.input export const speechTokenResponseSchema = z.object({ token: z.string(), }) +export type SpeechTokenResponse = z.output export const speechTokenContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/organization-accounts.test.ts b/apps/sim/lib/api/contracts/organization-accounts.test.ts index 6e9a19cfb03..6dd57746793 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.test.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.test.ts @@ -3,8 +3,39 @@ import { describe, expect, it } from 'vitest' import { addOrganizationAccountMcpProviderContract, listOrganizationAccountPeopleContract, + reconnectPersonalOrganizationAccountContract, + startOrganizationAccountConnectionContract, } from '@/lib/api/contracts/organization-accounts' +describe.each([ + startOrganizationAccountConnectionContract, + reconnectPersonalOrganizationAccountContract, +])('account connection response $path', (contract) => { + const invitationLink = 'https://sim.test/credential-groups/enroll/fixture-token' + const authorizationUrl = + 'https://sim.test/api/credential-groups/enroll/fixture-token/oauth/option-1?returnTo=search' + + it('accepts older enrollment-only responses and the additive direct OAuth URL', () => { + expect(contract.response.schema.parse({ invitationLink })).toEqual({ invitationLink }) + expect(contract.response.schema.parse({ invitationLink, authorizationUrl })).toEqual({ + invitationLink, + authorizationUrl, + }) + }) + + it('rejects malformed or oversized direct URLs', () => { + expect( + contract.response.schema.safeParse({ invitationLink, authorizationUrl: '/relative' }).success + ).toBe(false) + expect( + contract.response.schema.safeParse({ + invitationLink, + authorizationUrl: `https://sim.test/${'x'.repeat(8192)}`, + }).success + ).toBe(false) + }) +}) + describe('organization MCP provider creation contract', () => { const schema = addOrganizationAccountMcpProviderContract.body if (!schema) throw new Error('MCP provider creation requires a body contract') diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index def09b7e201..7b08459e082 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -19,6 +19,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT, ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT, + ORGANIZATION_VIEWER_ACCOUNT_LIMIT, } from '@/lib/credential-groups/limits' const organizationAccountsParamsSchema = z.object({ id: organizationIdSchema }) @@ -41,6 +42,19 @@ export const getOrganizationAccountsContract = defineRouteContract({ availableProviders: z.array(credentialGroupProviderSchema), canManage: z.boolean(), indexingAvailable: z.boolean(), + viewerAccounts: z + .array( + z.object({ + credentialId: z.string().min(1).max(128), + displayName: z.string().max(512), + providerId: z.string().min(1).max(128), + groupId: z.string().min(1).max(128), + optionId: z.string().min(1).max(128), + status: z.enum(['active', 'needs_reauth']), + }) + ) + .max(ORGANIZATION_VIEWER_ACCOUNT_LIMIT) + .optional(), }), }, }) @@ -58,12 +72,20 @@ export const updateOrganizationAccountsContract = defineRouteContract({ body: updateCredentialGroupBodySchema, response: { mode: 'json', schema: organizationAccountsResponseSchema }, }) +export const organizationAccountConnectionResponseSchema = z.object({ + invitationLink: z.string().url(), + authorizationUrl: z.string().url().max(8192).optional(), +}) +export type OrganizationAccountConnectionResponse = z.output< + typeof organizationAccountConnectionResponseSchema +> + export const startOrganizationAccountConnectionContract = defineRouteContract({ method: 'POST', path: '/api/organizations/[id]/connected-accounts/connect', params: organizationAccountsParamsSchema, body: z.object({ optionId: z.string().min(1, 'Account option is required').max(128) }).strict(), - response: { mode: 'json', schema: z.object({ invitationLink: z.string().url() }) }, + response: { mode: 'json', schema: organizationAccountConnectionResponseSchema }, }) export const startOrganizationSlackConfigurationContract = defineRouteContract({ @@ -313,7 +335,7 @@ export const reconnectPersonalOrganizationAccountContract = defineRouteContract( method: 'POST', path: '/api/users/me/organization-accounts/[credentialId]/reconnect', params: z.object({ credentialId: z.string().min(1).max(128) }), - response: { mode: 'json', schema: z.object({ invitationLink: z.string().url() }) }, + response: { mode: 'json', schema: organizationAccountConnectionResponseSchema }, }) export const disconnectPersonalOrganizationAccountContract = defineRouteContract({ method: 'DELETE', diff --git a/apps/sim/lib/api/server/credential-headers.ts b/apps/sim/lib/api/server/credential-headers.ts new file mode 100644 index 00000000000..f0488ceecdb --- /dev/null +++ b/apps/sim/lib/api/server/credential-headers.ts @@ -0,0 +1,20 @@ +/** + * The headers that carry external API credentials, and the header-only check + * for them. Deliberately dependency-free: the route wrapper classifies every + * request with this before any authentication happens, and it must not pull + * the authentication graph in to do so. + */ + +export const API_KEY_HEADER = 'x-api-key' +export const BEARER_PREFIX = 'Bearer ' + +/** + * Whether a request carries external API credentials — an API key or a bearer + * token. Inspects headers only and validates nothing: it classifies the + * request as programmatic API traffic rather than interactive session traffic. + */ +export function hasExternalApiCredentials(headers: { get(name: string): string | null }): boolean { + if (headers.get(API_KEY_HEADER) !== null) return true + const auth = headers.get('authorization') + return auth?.startsWith(BEARER_PREFIX) ?? false +} diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 385b21ebc45..44ed6713897 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -1,4 +1,5 @@ -import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { describePrincipalAuth, type Principal, type SessionPrincipal } from '@sim/auth/principal' +import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { @@ -90,6 +91,7 @@ export function defineInternalBinaryRoute< } throw error } + setRequestAuth(describePrincipalAuth(principal)) await options.rateLimit.enforce(request, principal) const parsed = await parseRequest(options.contract, request, context ?? {}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 32c7dd2a1e5..c931133d687 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { getRequestContext } from '@sim/logger' +import { getRequestContext, setRequestAuth } from '@sim/logger' import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' @@ -78,6 +78,27 @@ describe('defineInternalJsonRoute', () => { expect(response.headers.get('x-request-id')).toBeTruthy() }) + it('records how the request authenticated once the principal is known', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + }) + + await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith({ kind: 'session' }) + }) + it('applies a user-scoped admission limit after authentication and before execution', async () => { const execute = vi.fn(async () => ({ value: 'unreachable' })) mockEnforceUserRateLimit.mockResolvedValueOnce( @@ -492,6 +513,37 @@ describe('defineInternalJsonRoute', () => { }) }) + it('keeps every cookie a finalizer clears on its own header line', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + finalizeResponse: () => ({ + headers: new Headers([ + ['set-cookie', 'session_token=; Max-Age=0; Path=/'], + ['set-cookie', 'session_data=; Max-Age=0; Path=/'], + ]), + }), + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(200) + expect(response.headers.getSetCookie()).toEqual([ + 'session_token=; Max-Age=0; Path=/', + 'session_data=; Max-Age=0; Path=/', + ]) + }) + it('selects a declared success status from the application result', async () => { const replayableContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index ba0d6a5fe0e..a3890a53fde 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,13 +1,16 @@ import { type DelegatedPrincipal, + describePrincipalAuth, type Principal, resolvePrincipalSubjectUserId, type SessionPrincipal, type WorkflowExecutionDelegatedPrincipal, } from '@sim/auth/principal' +import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { ContractJsonResponse } from '@/lib/api/contracts' +import { API_KEY_HEADER, BEARER_PREFIX } from '@/lib/api/server/credential-headers' import { methodMatchesContract, requireJsonRouteDefinition, @@ -73,19 +76,19 @@ export function createInternalSessionOrExecutorAuth( return { async authenticate(request, params) { - if (request.headers.has('x-api-key')) { + if (request.headers.has(API_KEY_HEADER)) { throw new InternalUnauthenticatedError('Authentication required') } const authorization = request.headers.get('authorization') if (!authorization) return internalSessionAuth.authenticate() - if (!authorization.startsWith('Bearer ')) { + if (!authorization.startsWith(BEARER_PREFIX)) { throw new InternalUnauthenticatedError('Authentication required') } let delegation try { - delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + delegation = await verifyInternalDelegationToken(authorization.slice(BEARER_PREFIX.length)) } catch (error) { if (!(error instanceof InvalidInternalDelegationTokenError)) throw error throw new InternalUnauthenticatedError('Authentication required') @@ -327,6 +330,11 @@ function appendFinalizedHeaders(base: HeadersInit | undefined, additions?: Heade const headers = new Headers(base) if (!additions) return headers new Headers(additions).forEach((value, key) => { + /** A finalizer may clear several cookies at once; each needs its own header line. */ + if (key === 'set-cookie') { + headers.append(key, value) + return + } if (headers.has(key)) { throw new Error(`Internal JSON response finalizer cannot replace header "${key}"`) } @@ -365,6 +373,7 @@ export function defineInternalJsonRoute< } throw error } + setRequestAuth(describePrincipalAuth(principal)) const rateLimitResponse = await options.rateLimit.enforce(request, principal) if (rateLimitResponse) return responseWithRequestId(rateLimitResponse) diff --git a/apps/sim/lib/api/server/routes/scim-route.ts b/apps/sim/lib/api/server/routes/scim-route.ts index 1b965875110..f658a09009a 100644 --- a/apps/sim/lib/api/server/routes/scim-route.ts +++ b/apps/sim/lib/api/server/routes/scim-route.ts @@ -1,5 +1,5 @@ -import type { ScimConnectionPrincipal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' +import { describePrincipalAuth, type ScimConnectionPrincipal } from '@sim/auth/principal' +import { createLogger, setRequestAuth } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import type { AnyApiRouteContract, ContractJsonResponse } from '@/lib/api/contracts/types' import { type ParsedRequest, parseRequest } from '@/lib/api/server/validation' @@ -177,6 +177,7 @@ export function createScimRouteBuilder(dependencies: ScimRouteDependencies) { * authenticates and admits. */ principal = await dependencies.authenticate(request) + setRequestAuth(describePrincipalAuth(principal)) await enforceConnectionRateLimit(principal) const parsed = await parseRequest(options.contract, request, context ?? {}, { diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 374226b84dd..74c8d2ee22e 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -1,3 +1,5 @@ +import { describePrincipalAuth } from '@sim/auth/principal' +import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' @@ -337,6 +339,7 @@ async function admitAuthenticatedV2Request( } throw new V2RouteInfrastructureError('authentication', error) } + setRequestAuth(describePrincipalAuth(auth.principal)) try { requireOAuthOperationScope(auth.principal, operation) diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 819301f9e55..eac476540ca 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -1,6 +1,7 @@ -import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' +import { describePrincipalAuth, type WorkflowExecutionPrincipal } from '@sim/auth/principal' +import { createLogger, setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' +import { API_KEY_HEADER, BEARER_PREFIX } from '@/lib/api/server/credential-headers' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { type InternalSandboxProfile, verifyInternalToken } from '@/lib/auth/internal' @@ -15,20 +16,6 @@ export const AuthType = { export type AuthTypeValue = (typeof AuthType)[keyof typeof AuthType] -const API_KEY_HEADER = 'x-api-key' -const BEARER_PREFIX = 'Bearer ' - -/** - * Lightweight header-only check for whether a request carries external API credentials. - * Does NOT validate the credentials — only inspects headers to classify the request - * as programmatic API traffic vs interactive session traffic. - */ -export function hasExternalApiCredentials(headers: Headers): boolean { - if (headers.has(API_KEY_HEADER)) return true - const auth = headers.get('authorization') - return auth?.startsWith(BEARER_PREFIX) ?? false -} - export interface AuthResult { success: boolean userId?: string @@ -96,14 +83,14 @@ function resolveUserFromJwt( * @param options - Optional configuration * @param options.requireWorkflowId - Whether workflowId/userId is required (default: true) */ -export async function checkInternalAuth( +async function resolveInternalAuth( request: NextRequest, options: { requireWorkflowId?: boolean } = {} ): Promise { try { const authHeader = request.headers.get('authorization') - const apiKeyHeader = request.headers.get('x-api-key') + const apiKeyHeader = request.headers.get(API_KEY_HEADER) if (apiKeyHeader) { return { success: false, @@ -111,7 +98,7 @@ export async function checkInternalAuth( } } - if (!authHeader?.startsWith('Bearer ')) { + if (!authHeader?.startsWith(BEARER_PREFIX)) { return { success: false, error: 'Internal authentication required', @@ -144,13 +131,13 @@ export async function checkInternalAuth( * @param options - Optional configuration * @param options.requireWorkflowId - Whether workflowId/userId is required for JWT (default: true) */ -export async function checkSessionOrInternalAuth( +async function resolveSessionOrInternalAuth( request: NextRequest, options: { requireWorkflowId?: boolean } = {} ): Promise { try { // 1. Reject API keys first - const apiKeyHeader = request.headers.get('x-api-key') + const apiKeyHeader = request.headers.get(API_KEY_HEADER) if (apiKeyHeader) { return { success: false, @@ -160,7 +147,7 @@ export async function checkSessionOrInternalAuth( // 2. Check for internal JWT token const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ')) { + if (authHeader?.startsWith(BEARER_PREFIX)) { const token = authHeader.split(' ')[1] const verification = await verifyInternalToken(token) @@ -208,13 +195,13 @@ export async function checkSessionOrInternalAuth( * * For internal JWT calls, requires workflowId to determine user context */ -export async function checkHybridAuth( +async function resolveHybridAuth( request: NextRequest, options: { requireWorkflowId?: boolean } = {} ): Promise { try { const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ')) { + if (authHeader?.startsWith(BEARER_PREFIX)) { const token = authHeader.split(' ')[1] const verification = await verifyInternalToken(token) @@ -290,3 +277,36 @@ export async function checkHybridAuth( } } } + +type AuthCheck = ( + request: NextRequest, + options?: { requireWorkflowId?: boolean } +) => Promise + +/** + * Records how a request authenticated on the request context, so the logs and + * analytics of a route that authenticates through these helpers rather than a + * route builder carry the same `auth` attribution. A principal describes + * itself; an internal JWT that produced none is recorded by its auth type. + */ +function recordingAuth(resolve: AuthCheck): AuthCheck { + return async (request, options) => { + const result = await resolve(request, options) + if (!result.success) return result + if (result.principal) { + setRequestAuth(describePrincipalAuth(result.principal)) + } else if (result.authType) { + setRequestAuth({ kind: result.authType }) + } + return result + } +} + +/** Internal JWT authentication only. See {@link resolveInternalAuth}. */ +export const checkInternalAuth = recordingAuth(resolveInternalAuth) + +/** Session or internal JWT authentication, never an API key. See {@link resolveSessionOrInternalAuth}. */ +export const checkSessionOrInternalAuth = recordingAuth(resolveSessionOrInternalAuth) + +/** Any of the three supported credentials. See {@link resolveHybridAuth}. */ +export const checkHybridAuth = recordingAuth(resolveHybridAuth) diff --git a/apps/sim/lib/browser-agent/attachments.test.ts b/apps/sim/lib/browser-agent/attachments.test.ts index 4d23aaa27a7..267593cd47f 100644 --- a/apps/sim/lib/browser-agent/attachments.test.ts +++ b/apps/sim/lib/browser-agent/attachments.test.ts @@ -3,11 +3,8 @@ import { buildResourceAttachments } from '@/lib/browser-agent/attachments' import type { MothershipResource } from '@/lib/copilot/resources/types' import { useBrowserSessionStore } from '@/stores/browser-session/store' -const BROWSER_RESOURCE: MothershipResource = { - type: 'browser', - id: 'browser-session', - title: 'Browser', -} +const DOCS_TAB: MothershipResource = { type: 'browser', id: '1', title: 'Docs' } +const DASHBOARD_TAB: MothershipResource = { type: 'browser', id: '2', title: 'Dashboard' } describe('buildResourceAttachments', () => { beforeEach(() => { @@ -29,7 +26,7 @@ describe('buildResourceAttachments', () => { }) }) - it('adds every live browser tab and marks only the selected tab active', () => { + it('enriches every browser tab resource and marks only the selected tab active', () => { const store = useBrowserSessionStore.getState() store.setTabsState({ scopeId: 'chat-test', @@ -41,7 +38,6 @@ describe('buildResourceAttachments', () => { url: 'https://docs.sim.ai', loading: false, active: false, - pinned: false, }, { tabId: '2', @@ -49,22 +45,23 @@ describe('buildResourceAttachments', () => { url: 'https://sim.ai/workspace', loading: false, active: true, - pinned: false, }, ], }) - expect(buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id, 'chat-test')).toEqual([ + expect( + buildResourceAttachments([DOCS_TAB, DASHBOARD_TAB], DASHBOARD_TAB.id, 'chat-test') + ).toEqual([ { type: 'browser', - id: 'browser-session:1', + id: '1', title: 'Docs', active: false, url: 'https://docs.sim.ai', }, { type: 'browser', - id: 'browser-session:2', + id: '2', title: 'Dashboard', active: true, url: 'https://sim.ai/workspace', @@ -72,7 +69,7 @@ describe('buildResourceAttachments', () => { ]) }) - it('keeps all browser tabs open rather than active when another resource is selected', () => { + it('keeps a browser tab open rather than active when another resource is selected', () => { const store = useBrowserSessionStore.getState() store.setTabsState({ scopeId: 'chat-test', @@ -84,14 +81,13 @@ describe('buildResourceAttachments', () => { url: 'https://docs.sim.ai', loading: false, active: true, - pinned: false, }, ], }) - const attachments = buildResourceAttachments([BROWSER_RESOURCE], 'workflow-1', 'chat-test') + const attachments = buildResourceAttachments([DOCS_TAB], 'workflow-1', 'chat-test') - expect(attachments?.[0]).toMatchObject({ id: 'browser-session:1', active: false }) + expect(attachments?.[0]).toMatchObject({ id: '1', active: false }) }) it('reads attachments only from the requested chat scope', () => { @@ -106,7 +102,6 @@ describe('buildResourceAttachments', () => { url: 'https://a.example', loading: false, active: true, - pinned: false, }, ], }) @@ -120,16 +115,18 @@ describe('buildResourceAttachments', () => { url: 'https://b.example', loading: false, active: true, - pinned: false, }, ], }) - expect( - buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id, 'chat-a')?.[0] - ).toMatchObject({ title: 'A', url: 'https://a.example' }) - expect( - buildResourceAttachments([BROWSER_RESOURCE], BROWSER_RESOURCE.id, 'chat-b')?.[0] - ).toMatchObject({ title: 'B', url: 'https://b.example' }) + const sameIdTab: MothershipResource = { type: 'browser', id: 'same-id', title: 'Tab' } + expect(buildResourceAttachments([sameIdTab], sameIdTab.id, 'chat-a')?.[0]).toMatchObject({ + title: 'A', + url: 'https://a.example', + }) + expect(buildResourceAttachments([sameIdTab], sameIdTab.id, 'chat-b')?.[0]).toMatchObject({ + title: 'B', + url: 'https://b.example', + }) }) }) diff --git a/apps/sim/lib/browser-agent/attachments.ts b/apps/sim/lib/browser-agent/attachments.ts index d6c540657a8..ca11bc45ab5 100644 --- a/apps/sim/lib/browser-agent/attachments.ts +++ b/apps/sim/lib/browser-agent/attachments.ts @@ -2,12 +2,13 @@ * Maps the chat's open resources to request attachments. * * This is deliberately the ONLY place shared chat code reads the - * browser-session store: the live browser panel's page state is client-held - * (the desktop app's embedded browser), so its attachment is enriched here - * with the current URL and title for the server to inject as - * `@active_tab`/`@open_tab` context. A browser panel with no page loaded has + * browser-session store: a browser tab's page state is client-held (the + * desktop app's embedded browser), so its attachment is enriched here with + * the current URL and title for the server to inject as + * `@active_tab`/`@open_tab` context. A browser tab with no page loaded has * nothing to say and is dropped. */ +import { browserTabTitle } from '@/lib/browser-agent/tab-label' import type { MothershipResource } from '@/lib/copilot/resources/types' import { getBrowserSession } from '@/stores/browser-session/store' @@ -26,6 +27,7 @@ export function buildResourceAttachments( scopeId: string ): ResourceAttachment[] | undefined { const { tabs } = getBrowserSession(scopeId) + const tabsById = new Map(tabs.map((tab) => [tab.tabId, tab])) const attachments = resources.flatMap((resource) => { // The terminal panel is not addressable context: unlike a browser tab it // carries no URL to reference, and the shell's state reaches the model @@ -43,15 +45,17 @@ export function buildResourceAttachments( ] } - return tabs - .filter((tab) => Boolean(tab.url)) - .map((tab) => ({ + const tab = tabsById.get(resource.id) + if (!tab?.url) return [] + return [ + { type: resource.type, - id: `${resource.id}:${tab.tabId}`, - title: tab.title.trim() || resource.title, - active: resource.id === activeResourceId && tab.active, + id: resource.id, + title: browserTabTitle(tab), + active: resource.id === activeResourceId, url: tab.url, - })) + }, + ] }) if (attachments.length === 0) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.test.ts b/apps/sim/lib/browser-agent/tab-label.test.ts similarity index 84% rename from apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.test.ts rename to apps/sim/lib/browser-agent/tab-label.test.ts index 4afac4a57e9..2cd9c7ac070 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.test.ts +++ b/apps/sim/lib/browser-agent/tab-label.test.ts @@ -3,11 +3,8 @@ import { browserTabHostname, browserTabTitle, shouldShowBrowserTabSpinner, -} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label' +} from '@/lib/browser-agent/tab-label' -// Drop-index and title-truncation behaviour moved to the shared TabStrip in -// @sim/emcn along with the component; see its own tests. Only the browser's -// favicon lookup stays here. describe('browserTabHostname', () => { it('extracts hostnames from browser URLs', () => { expect(browserTabHostname('https://docs.sim.ai/guides')).toBe('docs.sim.ai') @@ -28,7 +25,6 @@ describe('browserTabTitle', () => { url: 'https://docs.sim.ai/guides', loading: false, active: false, - pinned: false, } it('never labels a settled blank-title page as loading', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts b/apps/sim/lib/browser-agent/tab-label.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts rename to apps/sim/lib/browser-agent/tab-label.ts diff --git a/apps/sim/lib/browser-agent/transport.test.ts b/apps/sim/lib/browser-agent/transport.test.ts index b384cea87a9..8775e04a457 100644 --- a/apps/sim/lib/browser-agent/transport.test.ts +++ b/apps/sim/lib/browser-agent/transport.test.ts @@ -28,9 +28,8 @@ const { openUrl, openUrlAvailable, panelAction, - reorderTab, - reorderStoreTab, registerSitePermissionPromptSupport, + reorderTab, restoreScope, nativeSuspendScope, setPageState, @@ -38,9 +37,7 @@ const { setPanelFocused, setPanelOccluded, setSessionAlive, - setTabPinned, showCredentialChooser, - showTabContextMenu, showToolbarMenu, setTheme, setTabsState, @@ -72,19 +69,16 @@ const { openUrl: vi.fn(), openUrlAvailable: { current: true }, panelAction: vi.fn(), - reorderTab: vi.fn(), - reorderStoreTab: vi.fn(), registerSitePermissionPromptSupport: vi.fn(), - restoreScope: vi.fn(), + reorderTab: vi.fn(), + restoreScope: vi.fn(async (scopeId: string) => ({ scopeId, tabs: [], activeTabId: null })), nativeSuspendScope: vi.fn(async () => true), setPageState: vi.fn(), setPanelBounds: vi.fn(), setPanelFocused: vi.fn(), setPanelOccluded: vi.fn(), setSessionAlive: vi.fn(), - setTabPinned: vi.fn(), showCredentialChooser: vi.fn(async () => true), - showTabContextMenu: vi.fn(), showToolbarMenu: vi.fn(), setTheme: vi.fn(), setTabsState: vi.fn(), @@ -122,8 +116,6 @@ vi.mock('@/lib/desktop', () => ({ setPanelBounds, setPanelFocused, setPanelOccluded, - setTabPinned, - showTabContextMenu, showToolbarMenu, setTheme, }, @@ -143,7 +135,6 @@ vi.mock('@/stores/browser-session/store', () => ({ activateScope, discardScope, migrateScope: migrateStoreScope, - reorderTab: reorderStoreTab, suspendScope: markScopeSuspended, setPageState, setSessionAlive, @@ -178,9 +169,7 @@ import { reportBrowserTheme, restoreBrowserScope, setBrowserPanelOccluded, - setBrowserTabPinned, showBrowserCredentialChooser, - showBrowserTabContextMenu, showBrowserToolbarMenu, supportsAtomicBrowserPanelOcclusion, suspendBrowserScope, @@ -196,9 +185,7 @@ describe('browser panel transport', () => { setPageState.mockClear() setSessionAlive.mockClear() setTabsState.mockClear() - reorderTab.mockClear() - reorderStoreTab.mockClear() - restoreScope.mockReset() + restoreScope.mockClear() nativeSuspendScope.mockReset() nativeSuspendScope.mockResolvedValue(true) markScopeSuspended.mockClear() @@ -213,8 +200,6 @@ describe('browser panel transport', () => { openTab.mockReset() openUrl.mockReset() openUrlAvailable.current = true - setTabPinned.mockClear() - showTabContextMenu.mockClear() showToolbarMenu.mockClear() onToolbarCommand.mockClear() onAddToChat.mockClear() @@ -241,7 +226,6 @@ describe('browser panel transport', () => { url: 'https://example.com', loading: false, active: false, - pinned: false, }, { tabId: '2', @@ -249,7 +233,6 @@ describe('browser panel transport', () => { url: '', loading: false, active: true, - pinned: false, }, ], } @@ -358,20 +341,10 @@ describe('browser panel transport', () => { expect(supportsAtomicBrowserPanelOcclusion()).toBe(true) }) - it('forwards tab pinning to the native browser', () => { - setBrowserTabPinned('tab-2', true) - setBrowserTabPinned('tab-2', false) - - expect(setTabPinned.mock.calls).toEqual([ - ['tab-2', true, 'chat-test'], - ['tab-2', false, 'chat-test'], - ]) - }) - - it('opens the native tab menu in the matching browser scope', () => { - showBrowserTabContextMenu('tab-2', 'chat-a') + it('mirrors a strip reorder into the native tab list', () => { + reorderBrowserTab('tab-3', 1) - expect(showTabContextMenu).toHaveBeenCalledWith('tab-2', 'chat-a') + expect(reorderTab).toHaveBeenCalledWith('tab-3', 1, 'chat-test') }) it('opens and scopes the native browser toolbar menu', () => { @@ -451,13 +424,6 @@ describe('browser panel transport', () => { expect(showCredentialChooser).toHaveBeenCalledWith({ x: 10, y: 20 }, 'chat-a') }) - it('forwards tab reordering to the native browser', () => { - reorderBrowserTab('tab-3', 1) - - expect(reorderStoreTab).toHaveBeenCalledWith('chat-test', 'tab-3', 1) - expect(reorderTab).toHaveBeenCalledWith('tab-3', 1, 'chat-test') - }) - it('forgets an abandoned provisional browser scope on both sides', async () => { await discardBrowserScope('pending:new') @@ -700,7 +666,6 @@ describe('browser panel transport', () => { title: 'Restored', loading: false, active: true, - pinned: false, }, ], activeTabId: '1', diff --git a/apps/sim/lib/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts index b599012d19a..fccf71880f5 100644 --- a/apps/sim/lib/browser-agent/transport.ts +++ b/apps/sim/lib/browser-agent/transport.ts @@ -100,7 +100,12 @@ export function initBrowserAgentTransport(): void { agent.registerSitePermissionPromptSupport?.() } -/** Makes one chat's browser set active in both renderer and desktop. */ +/** + * Makes one chat's browser set active in both renderer and desktop, then + * materializes its persisted pages. Each live page is a resource tab, so the + * tab list has to exist before any browser panel is mounted rather than being + * hydrated by the panel reporting its bounds. + */ export async function activateBrowserScope(scopeId: string): Promise { activeScopeId = scopeId useBrowserSessionStore.getState().activateScope(scopeId) @@ -108,11 +113,12 @@ export async function activateBrowserScope(scopeId: string): Promise { if (!agent) return const tabs = await agent.activateScope(scopeId) useBrowserSessionStore.getState().setTabsState(tabs) + if (tabs.tabs.length === 0 && activeScopeId === scopeId) await restoreBrowserScope(scopeId) } /** - * Materializes persisted tabs for a lazily activated scope without waiting for - * its React panel to mount and report native-view bounds. + * Materializes persisted tabs for a lazily activated scope. Safe to repeat: a + * scope that already hydrated answers with its live list. */ export async function restoreBrowserScope(scopeId: string): Promise { const agent = bridge() @@ -353,11 +359,15 @@ export async function openBrowserTab( return state } -/** Opens a distinct browser tab and navigates it after the shell accepts it. */ +/** + * Opens a distinct browser tab and navigates it after the shell accepts it. + * Resolves with the new tab's id, or null on older shells that only confirm + * through a later tab-state push. + */ export async function openUrlInNewBrowserTab( url: string, scopeId = currentBrowserScopeId() -): Promise { +): Promise { const agent = bridge() if (!agent) throw new Error('The Sim desktop browser agent is unavailable.') if (agent.openUrl) { @@ -366,36 +376,23 @@ export async function openUrlInNewBrowserTab( throw new Error('The desktop browser did not confirm the new tab.') } useBrowserSessionStore.getState().setTabsState(state) - return + return state.activeTabId } - await openBrowserTab(scopeId) + const state = await openBrowserTab(scopeId) sendBrowserPanelAction('navigate', { url }, scopeId) + return state?.activeTabId ?? null } -/** Pins or unpins a live browser tab. */ -export function setBrowserTabPinned( - tabId: string, - pinned: boolean, - scopeId = currentBrowserScopeId() -): void { - bridge()?.setTabPinned(tabId, pinned, scopeId) -} - -/** Opens the desktop shell's native menu for one browser tab. */ -export function showBrowserTabContextMenu(tabId: string, scopeId = currentBrowserScopeId()): void { - bridge()?.showTabContextMenu(tabId, scopeId) -} - -/** Moves a live browser tab to its final list index. */ +/** + * Mirrors a resource-strip reorder into the native tab list, so restore and + * the agent's tab list keep the strip's order. Older shells keep native order. + */ export function reorderBrowserTab( tabId: string, targetIndex: number, scopeId = currentBrowserScopeId() ): void { - const agent = bridge() - if (!agent) return - useBrowserSessionStore.getState().reorderTab(scopeId, tabId, targetIndex) - agent.reorderTab(tabId, targetIndex, scopeId) + bridge()?.reorderTab?.(tabId, targetIndex, scopeId) } /** Mirrors Sim's raw light/dark/system preference into embedded pages. */ diff --git a/apps/sim/lib/catalog/projection/projection-invariants.test.ts b/apps/sim/lib/catalog/projection/projection-invariants.test.ts index 044235e0781..e2c8243d923 100644 --- a/apps/sim/lib/catalog/projection/projection-invariants.test.ts +++ b/apps/sim/lib/catalog/projection/projection-invariants.test.ts @@ -20,6 +20,7 @@ vi.mock('@sim/logger', () => ({ logger: mockLogger, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: () => undefined, })) diff --git a/apps/sim/lib/copilot/application/load-search-integrations.test.ts b/apps/sim/lib/copilot/application/load-search-integrations.test.ts new file mode 100644 index 00000000000..6da78ddac74 --- /dev/null +++ b/apps/sim/lib/copilot/application/load-search-integrations.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { authorizeChat, listIntegrations } = vi.hoisted(() => ({ + authorizeChat: vi.fn(), + listIntegrations: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + authorizeOrganizationChatDelegation: { execute: authorizeChat }, +})) +vi.mock('@/lib/knowledge/application/personal-search-integrations', () => ({ + listPersonalSearchIntegrations: { execute: listIntegrations }, +})) + +import { loadCopilotSearchIntegrations } from '@/lib/copilot/application/load-search-integrations' +import type { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' + +type InventoryPage = Awaited> + +const context = { + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + messageId: 'message-1', +} +const emptyPage: InventoryPage = { + connections: [], + available: [], + completedCredentialId: null, + nextCursor: null, +} + +describe('loadCopilotSearchIntegrations', () => { + beforeEach(() => { + vi.clearAllMocks() + authorizeChat.mockResolvedValue(undefined) + listIntegrations.mockResolvedValue(emptyPage) + }) + + it('authorizes the private chat and reads only for the authenticated person and organization', async () => { + expect(await loadCopilotSearchIntegrations(context)).toBe('{"connections":[],"available":[]}') + const principal = authorizeChat.mock.calls[0][0].principal + expect(principal).toMatchObject({ + kind: 'organization_delegated', + serviceId: 'copilot', + subjectUserId: 'person-1', + organizationId: 'org-1', + delegationId: 'message-1', + audience: 'sim:knowledge', + resourceScope: { chatId: 'private-chat-1' }, + }) + expect(listIntegrations).toHaveBeenCalledExactlyOnceWith({ + principal, + input: { organizationId: 'org-1' }, + }) + expect(authorizeChat.mock.invocationCallOrder[0]).toBeLessThan( + listIntegrations.mock.invocationCallOrder[0] + ) + }) + + it('loads every page and preserves account status and exact connection controls', async () => { + const available: InventoryPage['available'][number] = { + name: 'Gmail', + description: 'Personal mail', + target: { type: 'link', provider: 'google-email', connectorType: 'gmail' }, + } + const connection: InventoryPage['connections'][number] = { + name: 'Gmail', + providerId: 'google-email', + connectorType: 'gmail', + connectorId: 'source-1', + knowledgeBaseId: 'kb-1', + description: 'Personal mail', + accounts: [ + { + credentialId: 'account-1', + displayName: 'me@example.com', + status: 'reconnect_needed', + action: { ...available.target, connectorId: 'source-1', credentialId: 'account-1' }, + }, + ], + connectionStatus: 'reconnect_needed', + indexingStatus: 'indexed', + searchableDocuments: 7, + action: null, + } + listIntegrations + .mockResolvedValueOnce({ ...emptyPage, available: [available], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ ...emptyPage, connections: [connection], available: [available] }) + + expect(JSON.parse(await loadCopilotSearchIntegrations(context))).toEqual({ + connections: [connection], + available: [available], + }) + expect(listIntegrations.mock.calls[1][0]).toEqual({ + principal: authorizeChat.mock.calls[0][0].principal, + input: { organizationId: 'org-1', cursor: 'page-2' }, + }) + }) + + it('does not read inventory when private-chat authorization fails', async () => { + authorizeChat.mockRejectedValueOnce(new Error('Chat belongs to another person')) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow( + 'Chat belongs to another person' + ) + expect(listIntegrations).not.toHaveBeenCalled() + }) + + it('fails the turn if a later page cannot be read', async () => { + listIntegrations + .mockResolvedValueOnce({ ...emptyPage, nextCursor: 'page-2' }) + .mockRejectedValueOnce(new Error('Inventory unavailable')) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow('Inventory unavailable') + }) + + it('rejects a repeated cursor instead of looping or returning partial inventory', async () => { + listIntegrations.mockResolvedValue({ ...emptyPage, nextCursor: 'page-2' }) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow( + 'pagination did not advance' + ) + expect(listIntegrations).toHaveBeenCalledTimes(2) + }) + + it('bounds page loading when the inventory never ends', async () => { + listIntegrations.mockImplementation(async () => ({ + ...emptyPage, + nextCursor: `page-${listIntegrations.mock.calls.length + 1}`, + })) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow('pagination limit') + expect(listIntegrations).toHaveBeenCalledTimes(100) + }) + + it('rejects oversized prompt content instead of silently truncating it', async () => { + listIntegrations.mockResolvedValueOnce({ + ...emptyPage, + available: [ + { + name: 'Gmail', + description: 'x'.repeat(256 * 1024), + target: { type: 'link', provider: 'google-email', connectorType: 'gmail' }, + }, + ], + }) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow('prompt size limit') + }) + + it('stops loading when the turn is cancelled between pages', async () => { + const controller = new AbortController() + listIntegrations.mockImplementationOnce(async () => { + controller.abort(new Error('Turn cancelled')) + return { ...emptyPage, nextCursor: 'page-2' } + }) + await expect( + loadCopilotSearchIntegrations({ ...context, signal: controller.signal }) + ).rejects.toThrow('Turn cancelled') + expect(listIntegrations).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/copilot/application/load-search-integrations.ts b/apps/sim/lib/copilot/application/load-search-integrations.ts new file mode 100644 index 00000000000..4627bf7a8b4 --- /dev/null +++ b/apps/sim/lib/copilot/application/load-search-integrations.ts @@ -0,0 +1,63 @@ +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/copilot/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' + +const MAX_INVENTORY_PAGES = 100 +const MAX_INVENTORY_BYTES = 256 * 1024 + +interface SearchIntegrationsContext { + userId: string + organizationId: string + chatId: string + messageId: string + signal?: AbortSignal +} + +type IntegrationInventory = Awaited> + +/** Loads the complete current person's Search inventory for one authenticated chat turn. */ +export async function loadCopilotSearchIntegrations( + context: SearchIntegrationsContext +): Promise { + context.signal?.throwIfAborted() + const principal = createTrustedOrganizationCopilotPrincipal( + { ...context, delegationId: context.messageId }, + { + audience: knowledgeDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + } + ) + await authorizeOrganizationChatDelegation.execute({ principal }) + + const connections: IntegrationInventory['connections'] = [] + const available = new Map() + const cursors = new Set() + let cursor: string | undefined + for (let pageNumber = 0; pageNumber < MAX_INVENTORY_PAGES; pageNumber++) { + context.signal?.throwIfAborted() + const page = await listPersonalSearchIntegrations.execute({ + principal, + input: { organizationId: context.organizationId, ...(cursor ? { cursor } : {}) }, + }) + context.signal?.throwIfAborted() + connections.push(...page.connections) + for (const entry of page.available) { + available.set(JSON.stringify(entry.target), entry) + } + const inventory = JSON.stringify({ connections, available: [...available.values()] }) + if (Buffer.byteLength(inventory) > MAX_INVENTORY_BYTES) { + throw new Error('Search integration inventory exceeds the prompt size limit') + } + if (page.nextCursor === null) return inventory + if (cursors.has(page.nextCursor)) { + throw new Error('Search integration inventory pagination did not advance') + } + cursors.add(page.nextCursor) + cursor = page.nextCursor + } + throw new Error('Search integration inventory exceeds the pagination limit') +} diff --git a/apps/sim/lib/copilot/assistant/tool-policy.ts b/apps/sim/lib/copilot/assistant/tool-policy.ts index 2ee6fe3c3b3..4289ad9660d 100644 --- a/apps/sim/lib/copilot/assistant/tool-policy.ts +++ b/apps/sim/lib/copilot/assistant/tool-policy.ts @@ -3,7 +3,6 @@ import type { ToolMetadata } from '@/tools/metadata' export const ASSISTANT_TOOLS = new Set([ 'search_workspace', 'read_document', - 'list_integrations', 'search_integration_tools', 'call_integration_tool', 'oauth_get_auth_link', diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 8ea9c27ca2c..6a0d6914736 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -601,7 +601,7 @@ describe('handleUnifiedChatPost', () => { ) }) - it('persists browser page attachments as one canonical Browser panel', async () => { + it('never persists browser tab attachments, which the desktop app restores itself', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { method: 'POST', @@ -612,14 +612,14 @@ describe('handleUnifiedChatPost', () => { resourceAttachments: [ { type: 'browser', - id: 'browser-session:slack-tab', + id: '3', title: 'mship-todo (Channel) - sim - Slack', active: true, url: 'https://app.slack.com/client/workspace/channel', }, { type: 'browser', - id: 'browser-session:docs-tab', + id: '4', title: 'Docs', url: 'https://docs.example.com', }, @@ -629,9 +629,7 @@ describe('handleUnifiedChatPost', () => { ) expect(response.status).toBe(200) - expect(persistChatResources).toHaveBeenCalledWith('chat-1', [ - { type: 'browser', id: 'browser-session', title: 'Browser' }, - ]) + expect(persistChatResources).not.toHaveBeenCalled() }) it('forwards the desktop local filesystem capability into payload construction', async () => { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 4ad175e095e..ba2f04f7afe 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -155,8 +155,8 @@ const GENERIC_RESOURCE_TITLE: Record['t /** * Synthetic client-side panels are context-only: never persisted to the chat. - * Browser tab attachments are normalized to the singleton Browser panel before - * persistence; their page title and URL remain request context only. + * Browser tabs are among them — the desktop app restores its own pages — so + * their page title and URL remain request context only. */ function isPersistableAttachment(resource: z.infer): boolean { return !isEphemeralResource({ @@ -1336,9 +1336,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { actualChatId && body.resourceAttachments?.length ) { - // Canonicalizes here, not just inside `persistChatResources`: several - // browser tabs collapse onto the one Browser panel before they are - // stored, so the chat reopens with a single tab rather than one per page. + // Canonicalizes here, not just inside `persistChatResources`, so the + // singleton terminal panel is stored once however it was attached. const persistable = sanitizeChatResources( body.resourceAttachments.filter(isPersistableAttachment).map((resource) => ({ type: resource.type, diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 3d35d408e0d..1e58c2db153 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -529,11 +529,11 @@ describe('processContextsServer - MCP contexts', () => { }) describe('processContextsServer - browser and terminal selections', () => { - it('describes whole Browser and Terminal mentions without inventing tab ids', async () => { + it('points every browser and terminal mention at its exact tab', async () => { const result = await processContextsServer( [ - { kind: 'browser_tab', tabId: 'browser-session', label: 'Browser' }, - { kind: 'terminal_tab', terminalId: 'terminal-session', label: 'Terminal' }, + { kind: 'browser_tab', tabId: '3', label: 'Sim Docs' }, + { kind: 'terminal_tab', terminalId: '4', label: 'sim' }, ], 'user-1' ) @@ -541,17 +541,17 @@ describe('processContextsServer - browser and terminal selections', () => { expect(result).toMatchObject([ { type: 'browser_tab', - tag: '@Browser', - content: expect.stringContaining('resource as a whole'), + tag: '@Sim Docs', + content: expect.stringContaining('tabId 3'), }, { type: 'terminal_tab', - tag: '@Terminal', - content: expect.stringContaining('resource as a whole'), + tag: '@sim', + content: expect.stringContaining('terminalId 4'), }, ]) - expect(result[0].content).toContain('browser_list_tabs') - expect(result[1].content).toContain('terminal list operation') + expect(result[0].content).toContain('browser_switch_tab') + expect(result[1].content).toContain('pass that terminalId') }) it('keeps the live browser pointer and appends quoted untrusted page text', async () => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 4240df5ac17..028b499bdf9 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -19,10 +19,6 @@ import { truncateSelectionText, } from '@/lib/copilot/chat/selection-context' import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' -import { - BROWSER_SESSION_RESOURCE_ID, - TERMINAL_SESSION_RESOURCE_ID, -} from '@/lib/copilot/resources/types' import { canonicalBlockVfsPath, canonicalKnowledgeBaseVfsDir, @@ -226,14 +222,6 @@ export async function processContextsServer( // additionally carries the quoted snapshot they chose, while the pointer // lets the agent inspect or act on the current page/shell when needed. if (ctx.kind === 'browser_tab' && ctx.tabId) { - if (ctx.tabId === BROWSER_SESSION_RESOURCE_ID) { - return { - type: 'browser_tab', - tag: ctx.label ? `@${ctx.label}` : '@Browser', - content: - 'The user tagged the Browser resource as a whole, not a specific tab. Inspect the live tabs with browser_list_tabs and choose the relevant one from their request. If no browser tab is open yet, open or navigate one as needed.', - } - } const pointer = `The user pointed at an open browser tab: "${ctx.label}" (tabId ${ctx.tabId}). Act on THIS tab — switch to it with browser_switch_tab and read it with browser_snapshot rather than assuming which tab they meant.` return { type: 'browser_tab', @@ -244,14 +232,6 @@ export async function processContextsServer( } } if (ctx.kind === 'terminal_tab' && ctx.terminalId) { - if (ctx.terminalId === TERMINAL_SESSION_RESOURCE_ID) { - return { - type: 'terminal_tab', - tag: ctx.label ? `@${ctx.label}` : '@Terminal', - content: - 'The user tagged the Terminal resource as a whole, not a specific shell. Inspect the live terminals with the terminal list operation and choose the relevant one from their request. If no terminal is open yet, create one as needed.', - } - } const pointer = `The user pointed at an open terminal: "${ctx.label}" (terminalId ${ctx.terminalId}). Act on THIS terminal — pass that terminalId to the terminal tool, and read its screen before assuming what is in it.` return { type: 'terminal_tab', diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index ba1946bc3d8..69d6daa5aab 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -47,8 +47,10 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/selectors.mdx', 'cli/skills.mdx', 'cli/tables.mdx', + 'cli/telemetry.mdx', 'cli/tools.mdx', 'cli/troubleshooting.mdx', + 'cli/usage-data.mdx', 'cli/workflow-mcp-servers.mdx', 'cli/workflow-sync.mdx', 'cli/workflows.mdx', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 614726dfe67..b65bebaf7d1 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -74,7 +74,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_integrations' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -213,7 +212,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_integrations' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -3543,21 +3541,6 @@ export const ListIntegrationTools: ToolCatalogEntry = { }, } -export const ListIntegrations: ToolCatalogEntry = { - id: 'list_integrations', - name: 'list_integrations', - route: 'sim', - mode: 'async', - parameters: { - additionalProperties: false, - properties: { - connectorType: { maxLength: 100, minLength: 1, type: 'string' }, - cursor: { maxLength: 1024, minLength: 1, type: 'string' }, - }, - type: 'object', - }, -} - export const ListWorkspaceMcpServers: ToolCatalogEntry = { id: 'list_workspace_mcp_servers', name: 'list_workspace_mcp_servers', @@ -7664,7 +7647,6 @@ export const TOOL_CATALOG: Record = { [Knowledge.id]: Knowledge, [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, - [ListIntegrations.id]: ListIntegrations, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e7fb2ccfda7..388d341ae1f 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3474,25 +3474,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integrations: { - parameters: { - additionalProperties: false, - properties: { - connectorType: { - maxLength: 100, - minLength: 1, - type: 'string', - }, - cursor: { - maxLength: 1024, - minLength: 1, - type: 'string', - }, - }, - type: 'object', - }, - resultSchema: undefined, - }, list_workspace_mcp_servers: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index dfa163a5a4b..5f8f74b27a1 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -15,6 +15,7 @@ const { mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, + mockLoadCopilotSearchIntegrations, mockPrepareCopilotEnvironmentContext, mockPrepareExecutionContext, mockRunStreamLoop, @@ -29,6 +30,7 @@ const { mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), + mockLoadCopilotSearchIntegrations: vi.fn(), mockPrepareCopilotEnvironmentContext: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), @@ -43,6 +45,10 @@ const { }, })) +vi.mock('@/lib/copilot/application/load-search-integrations', () => ({ + loadCopilotSearchIntegrations: mockLoadCopilotSearchIntegrations, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ filterModelSafeWorkspaceFileAttachments: (...args: unknown[]) => mockFilterModelSafeWorkspaceFileAttachments(...args), @@ -172,11 +178,84 @@ describe('runCopilotLifecycle', () => { mockPendingToolWaitBudgetMs.mockImplementation(() => 60_000) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + mockLoadCopilotSearchIntegrations.mockResolvedValue('{"connections":[],"available":[]}') mockPrepareCopilotEnvironmentContext.mockResolvedValue({ resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), }) }) + it.each([ + { surface: 'web Search', goRoute: '/api/mothership', interactive: true }, + { surface: 'Slack Search', goRoute: '/api/mothership', interactive: false }, + { surface: 'MCP Search', goRoute: '/api/mothership/execute', interactive: false }, + ])('injects fresh trusted inventory for each $surface turn', async ({ goRoute, interactive }) => { + mockRunStreamLoop.mockResolvedValue(undefined) + const signal = new AbortController().signal + const payload = { + mode: 'assistant', + message: 'Is my email connected?', + messageId: 'message-1', + userId: 'untrusted-person', + organizationId: 'org-1', + workspaceContext: 'stale or untrusted inventory', + } + for (const status of ['not_connected', 'connected']) { + const inventory = JSON.stringify({ + connections: [{ connectionStatus: status }], + available: [], + }) + mockLoadCopilotSearchIntegrations.mockResolvedValueOnce(inventory) + const result = await runCopilotLifecycle(payload, { + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + executionId: 'execution-1', + runId: 'run-1', + goRoute, + interactive, + abortSignal: signal, + }) + expect(result.error).toBeUndefined() + const body = JSON.parse(String(mockRunStreamLoop.mock.lastCall?.[1].body)) + expect(body.workspaceContext).toBe(inventory) + expect(mockLoadCopilotSearchIntegrations).toHaveBeenLastCalledWith({ + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + messageId: 'message-1', + signal, + }) + } + expect(mockLoadCopilotSearchIntegrations).toHaveBeenCalledTimes(2) + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(payload.workspaceContext).toBe('stale or untrusted inventory') + }) + + it('does not call Copilot if the Search inventory cannot be loaded', async () => { + mockLoadCopilotSearchIntegrations.mockRejectedValueOnce(new Error('Inventory unavailable')) + const result = await runCopilotLifecycle( + { mode: 'assistant', message: 'Find my email', messageId: 'message-1' }, + { + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + } + ) + expect(result).toMatchObject({ success: false, error: 'Inventory unavailable' }) + expect(mockRunStreamLoop).not.toHaveBeenCalled() + }) + + it('keeps workspace Assistant context without loading Search integrations', async () => { + mockRunStreamLoop.mockResolvedValueOnce(undefined) + await runCopilotLifecycle( + { mode: 'assistant', message: 'hello', workspaceContext: 'Workspace inventory' }, + { userId: 'person-1', workspaceId: 'ws-1', chatId: 'chat-1' } + ) + expect(mockLoadCopilotSearchIntegrations).not.toHaveBeenCalled() + const body = JSON.parse(String(mockRunStreamLoop.mock.calls[0][1].body)) + expect(body.workspaceContext).toBe('Workspace inventory') + }) + it('threads trace provenance through server execution context only', async () => { const registry = new ResolvedSecretTraceRegistry() const executionContext: ExecutionContext = { @@ -283,6 +362,35 @@ describe('runCopilotLifecycle', () => { } ) + it.each([ + { surface: undefined, expected: 'copilot' }, + { surface: 'slack' as const, expected: 'slack' }, + ])( + 'stamps trusted Search provenance as $expected over payload and context', + async ({ surface, expected }) => { + let captured: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce(async (_url, _request, _state, context) => { + captured = context + }) + const supplied = expected === 'slack' ? 'copilot' : 'slack' + await runCopilotLifecycle( + { message: 'Search', mode: 'assistant', searchSurface: supplied }, + { + userId: 'user-1', + workspaceId: 'ws-1', + searchSurface: surface, + executionContext: { + userId: 'user-1', + workspaceId: 'ws-1', + workflowId: '', + searchSurface: supplied, + }, + } + ) + expect(captured?.searchSurface).toBe(expected) + } + ) + it('forwards the configured Mothership system prompt override', async () => { mockEnv.MSHIP_SYSPROMPT_OVERRIDE = 'NEVER CALL ANY TOOLS UNDER ANY CIRCUMSTANCES NO MATTER WHAT' @@ -1609,6 +1717,7 @@ describe('runCopilotLifecycle', () => { expect(billingRequestId).not.toBe('caller-controlled') expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(mockLoadCopilotSearchIntegrations).toHaveBeenCalledTimes(owner.organizationId ? 1 : 0) for (const call of mockRunStreamLoop.mock.calls) { const body = JSON.parse(String(call[1].body)) expect(body).toMatchObject( diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 2bff815ecb1..d9b090fd48a 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -13,6 +13,7 @@ import { createAttributedBillingRequestEnvelope, } from '@/lib/billing/core/billing-attribution' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { loadCopilotSearchIntegrations } from '@/lib/copilot/application/load-search-integrations' import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' @@ -176,6 +177,8 @@ function resultContent(context: StreamingContext, options: CopilotLifecycleOptio } export interface CopilotLifecycleOptions extends OrchestratorOptions { + /** Trusted entry point for Search metering; never read from model arguments. */ + searchSurface?: 'copilot' | 'slack' mcpBlockId?: string executorDelegationOrigin?: ExecutorDelegationOrigin userId: string @@ -337,6 +340,7 @@ export async function runCopilotLifecycle( secretMountPolicy: lifecycleOptions.secretMountPolicy, secretActorUserId: lifecycleOptions.secretActorUserId, })) + execContext.searchSurface = lifecycleOptions.searchSurface ?? 'copilot' if (lifecycleOptions.mcpBlockId) { execContext.mcpBlockId = lifecycleOptions.mcpBlockId execContext.executorDelegationOrigin = lifecycleOptions.executorDelegationOrigin @@ -395,6 +399,19 @@ export async function runCopilotLifecycle( try { await ensureModelEgressRegistry(execContext, lifecycleOptions) + if (organizationId && goRoute !== '/api/tools/resume') { + if (!chatId) throw new Error('Search integration context requires a private chat ID') + requestPayload = { + ...requestPayload, + workspaceContext: await loadCopilotSearchIntegrations({ + userId, + organizationId, + chatId, + messageId: payloadMsgId, + signal: lifecycleOptions.abortSignal, + }), + } + } const modelSafeRequestPayload = await filterInitialCopilotAttachmentsForModel( requestPayload, lifecycleOptions.workspaceId diff --git a/apps/sim/lib/copilot/resources/availability.ts b/apps/sim/lib/copilot/resources/availability.ts index 442ce06e2eb..53445e10b9a 100644 --- a/apps/sim/lib/copilot/resources/availability.ts +++ b/apps/sim/lib/copilot/resources/availability.ts @@ -5,12 +5,9 @@ import { isTerminalAvailable } from '@/lib/terminal/transport' /** * Whether this client can show the resource's panel at all. * - * The browser and terminal panels are stored with the chat like any other - * resource, so the tab is still there when the chat is reopened. But they are - * windows onto something the desktop app owns — an embedded browser view, a - * pty — and opening that same chat in the web app would otherwise restore a - * tab that leads to an error. Such resources stay in the chat's stored - * resources either way; this only decides whether to put them on screen. + * Browser and terminal tabs are windows onto something the desktop app owns — + * an embedded browser view, a pty — so the web app has nothing to show for + * them. This only decides whether to put a resource on screen. */ export function canDisplayResource(resource: MothershipResource): boolean { if (!isDesktopOnlyResource(resource)) return true diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index 7510ad5d36a..e8febfb1a93 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' import { addCopilotChatResourceBodySchema } from '@/lib/api/contracts/copilot' import { - BROWSER_SESSION_RESOURCE_ID, - canonicalizeDesktopSessionResource, isAddressableResource, isDesktopOnlyResource, isEphemeralResource, @@ -13,7 +11,6 @@ import { PERSISTED_RESOURCE_TYPES, reorderStoredChatResources, sanitizeChatResources, - TERMINAL_SESSION_RESOURCE_ID, } from './types' function resource(overrides: Partial = {}): MothershipResource { @@ -21,17 +18,9 @@ function resource(overrides: Partial = {}): MothershipResour } describe('isEphemeralResource', () => { - it('persists the desktop panels so their tabs survive reopening the chat', () => { - expect( - isEphemeralResource( - resource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' }) - ) - ).toBe(false) - expect( - isEphemeralResource( - resource({ type: 'terminal', id: TERMINAL_SESSION_RESOURCE_ID, title: 'Terminal' }) - ) - ).toBe(false) + it('keeps browser and terminal tabs client-only because the desktop app restores them', () => { + expect(isEphemeralResource(resource({ type: 'browser', id: '3', title: 'Slack' }))).toBe(true) + expect(isEphemeralResource(resource({ type: 'terminal', id: '3', title: 'sim' }))).toBe(true) }) it('keeps synthetic panels client-only', () => { @@ -57,7 +46,7 @@ describe('isDesktopOnlyResource', () => { }) describe('desktop session resource identity', () => { - it('keeps browser pages as inner tabs of one canonical Browser resource', () => { + it('drops stored browser rows, which older clients persisted per page or as one panel', () => { expect( sanitizeChatResources([ resource({ @@ -65,20 +54,19 @@ describe('desktop session resource identity', () => { id: 'browser-session:slack-tab', title: 'mship-todo (Channel) - sim - Slack', }), - resource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' }), + resource({ type: 'browser', id: 'browser-session', title: 'Browser' }), + resource({ type: 'file', id: 'file-1', title: 'report.csv' }), ]) - ).toEqual([{ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' }]) + ).toEqual([{ type: 'file', id: 'file-1', title: 'report.csv' }]) }) - it('canonicalizes terminal inner-tab metadata without changing regular resources', () => { + it('drops stored terminal rows the same way', () => { expect( - canonicalizeDesktopSessionResource( - resource({ type: 'terminal', id: 'terminal-session:2', title: 'zsh' }) - ) - ).toEqual({ type: 'terminal', id: TERMINAL_SESSION_RESOURCE_ID, title: 'Terminal' }) - - const file = resource({ type: 'file', id: 'file-1', title: 'report.csv' }) - expect(canonicalizeDesktopSessionResource(file)).toBe(file) + sanitizeChatResources([ + resource({ type: 'terminal', id: 'terminal-session', title: 'Terminal' }), + resource({ type: 'file', id: 'file-1', title: 'report.csv' }), + ]) + ).toEqual([{ type: 'file', id: 'file-1', title: 'report.csv' }]) }) }) @@ -161,17 +149,6 @@ describe('unaddressable resources', () => { ]) }) - it('keeps the desktop panels, which are given their ids by canonicalization', () => { - const sanitized = sanitizeChatResources([ - resource({ type: 'browser', id: '', title: 'Browser' }), - resource({ type: 'terminal', id: '', title: 'Terminal' }), - ]) - expect(sanitized.map((r) => r.id)).toEqual([ - BROWSER_SESSION_RESOURCE_ID, - TERMINAL_SESSION_RESOURCE_ID, - ]) - }) - it('refuses a blank id at the write boundary, matching the send path', () => { const parsed = addCopilotChatResourceBodySchema.safeParse({ chatId: 'chat-1', diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index b437f686901..159fe16cc62 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -86,8 +86,11 @@ const RESOURCE_POLICY: Record = { integration: { persisted: true }, // A synthetic panel with no addressable entity behind it to reopen. generic: { persisted: false }, - browser: { persisted: true, desktopOnly: true }, - terminal: { persisted: true, desktopOnly: true }, + // One tab per live desktop page or shell, keyed by the native id. The + // desktop app owns those lists and restores them itself, so the chat row + // never stores these; they are re-derived from the live lists on open. + browser: { persisted: false, desktopOnly: true }, + terminal: { persisted: false, desktopOnly: true }, } /** @@ -116,39 +119,6 @@ export function isEphemeralResource(resource: MothershipResource): boolean { return !RESOURCE_POLICY[resource.type]?.persisted } -/** - * Singleton id for the live browser-session panel, which hosts the desktop - * app's natively embedded browser view. Only this metadata is stored with the - * chat: reopening restores the tab, while the page and browser profile stay - * owned by the desktop app. - */ -export const BROWSER_SESSION_RESOURCE_ID = 'browser-session' - -/** - * Singleton id for the live terminal panel. As with the browser, only the - * metadata is stored — reopening the chat brings the panel back with a fresh - * shell, since the pty and its scrollback belong to the desktop app and do not - * outlive it. - */ -export const TERMINAL_SESSION_RESOURCE_ID = 'terminal-session' - -/** - * Collapses page/shell-shaped metadata onto the one top-level desktop panel - * each chat can restore. Browser pages and terminal tabs are inner tabs, not - * independently addressable Mothership resources. - */ -export function canonicalizeDesktopSessionResource( - resource: MothershipResource -): MothershipResource { - if (resource.type === 'browser') { - return { type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser' } - } - if (resource.type === 'terminal') { - return { type: 'terminal', id: TERMINAL_SESSION_RESOURCE_ID, title: 'Terminal' } - } - return resource -} - /** * Whether an id value names something the app can act on. * @@ -174,38 +144,27 @@ export function isAddressableResource(resource: MothershipResource): boolean { } /** - * Canonicalizes and deduplicates the singleton desktop panels in display order. - * Module-private: callers want {@link sanitizeChatResources}, which also drops - * unaddressable resources. + * Drops browser and terminal rows: older clients stored the desktop panels on + * the chat, but their live tabs are derived from the desktop app rather than + * the chat row. Module-private: callers want {@link sanitizeChatResources}, + * which also drops unaddressable resources. */ -function canonicalizeDesktopSessionResources( +function withoutDesktopSessionResources( resources: readonly MothershipResource[] ): MothershipResource[] { - const seenDesktopTypes = new Set<'browser' | 'terminal'>() - const canonical: MothershipResource[] = [] - - for (const resource of resources) { - if (resource.type === 'browser' || resource.type === 'terminal') { - if (seenDesktopTypes.has(resource.type)) continue - seenDesktopTypes.add(resource.type) - } - canonical.push(canonicalizeDesktopSessionResource(resource)) - } - - return canonical + return resources.filter((resource) => !RESOURCE_POLICY[resource.type]?.desktopOnly) } /** - * The canonical form of a chat's resource list: singleton desktop panels - * collapsed, unaddressable resources dropped. Every path that reads or writes - * stored resources goes through this, which is what heals chats that already - * hold one. Canonicalization runs first, so the browser and terminal panels — - * which are given their ids there — are never dropped for arriving without one. + * The canonical form of a chat's resource list: legacy desktop panel rows and + * unaddressable resources dropped. Every path that reads or writes stored + * resources goes through this, which is what heals chats that already hold + * one. */ export function sanitizeChatResources( resources: readonly MothershipResource[] ): MothershipResource[] { - return canonicalizeDesktopSessionResources(resources).filter(isAddressableResource) + return withoutDesktopSessionResources(resources).filter(isAddressableResource) } /** diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 9c4be645f05..132d406aeb3 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -88,6 +88,26 @@ describe('copilot tool executor fallback', () => { } ) + it.each([{ organizationId: 'org-1' }, { workspaceId: 'workspace-1' }])( + 'rejects the retired inventory tool before dispatch for %j', + async (scope) => { + const handler = vi.fn() + registerHandler('list_integrations', handler) + const result = await executeTool( + 'list_integrations', + {}, + { + userId: 'user-1', + requestMode: 'assistant', + ...scope, + } + ) + expect(result.success).toBe(false) + expect(handler).not.toHaveBeenCalled() + expect(executeAppTool).not.toHaveBeenCalled() + } + ) + it.each([ ['run_workflow', undefined], ['gmail_send', undefined], diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index da8f2ac259b..7b0503f3bf7 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -48,12 +48,11 @@ export async function executeTool( (context.workspaceId || context.workflowId || context.requestMode !== 'assistant' || - !['search_workspace', 'read_document', 'list_integrations'].includes(toolId)) + !['search_workspace', 'read_document'].includes(toolId)) ) { return { success: false, - error: - 'Organization Assistant can search documents and inspect personal Search integrations.', + error: 'Organization Assistant can search and read documents.', } } if (context.requestMode === 'assistant' && !ASSISTANT_TOOLS.has(toolId)) { diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index a6ebadf787b..e781f5e36c2 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -6,6 +6,8 @@ import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export interface ToolExecutionContext { + /** Trusted entry point for Search metering; never read from model arguments. */ + searchSurface?: 'copilot' | 'slack' mcpBlockId?: string executorDelegationOrigin?: ExecutorDelegationOrigin userId: string diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts index f65a8b2ab70..11462df50a0 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -80,4 +80,24 @@ describe('server tool adapter authority boundary', () => { storageError ) }) + + it('forwards trusted Search provenance independently of model parameters', async () => { + await createServerToolHandler('search_workspace')( + { query: 'policy', searchSurface: 'copilot', surface: 'copilot' }, + { + userId: 'user-1', + workflowId: '', + organizationId: 'org-1', + chatId: 'chat-1', + toolCallId: 'call-1', + copilotToolExecution: true, + searchSurface: 'slack', + } + ) + expect(mocks.routeExecution).toHaveBeenCalledWith( + 'search_workspace', + expect.objectContaining({ query: 'policy' }), + expect.objectContaining({ searchSurface: 'slack', organizationId: 'org-1' }) + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 5742c431148..f7c37bbf2bd 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -24,6 +24,7 @@ export function createServerToolHandler(toolId: string): ToolHandler { toolCallId: context.toolCallId, copilotToolExecution: context.copilotToolExecution, requestMode: context.requestMode, + searchSurface: context.searchSurface, assistantSearch: context.assistantSearch, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index eacf3d152b4..96b6df6f3bd 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -4,6 +4,8 @@ import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export interface ServerToolContext { + /** Trusted entry point for Search metering; never read from model arguments. */ + searchSurface?: 'copilot' | 'slack' requestMode?: string assistantSearch?: WorkspaceSearchFilters userId: string diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 17b3a987c3b..298d5d7f270 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -420,6 +420,22 @@ describe('manage_knowledge_base trusted application delegation', () => { expect(mockReadKnowledgeBase).not.toHaveBeenCalled() }) + it('attributes knowledge queries to the trusted Slack context', async () => { + const result = await knowledgeBaseServerTool.execute( + { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, + { + ...CONTEXT, + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + searchSurface: 'slack', + } + ) + + expect(result.success).toBe(true) + expect(mockSearchKnowledge).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ surface: 'slack' }) }) + ) + }) + it('asks for citations where per-member access is on', async () => { const result = await knowledgeBaseServerTool.execute( { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 4781eb1ca8a..a610f02b6b2 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -449,7 +449,7 @@ export const knowledgeBaseServerTool: BaseServerTool ({ read: vi.fn(), authorizeChat: vi.fn() })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ - authorizeOrganizationChatDelegation: { execute: m.authorizeChat }, -})) -vi.mock('@/lib/knowledge/application/personal-search-integrations', () => ({ - listPersonalSearchIntegrations: { - get operation() { - return knowledgeOperations.listPersonalSearchIntegrations - }, - execute: m.read, - }, -})) - -import { listIntegrationsServerTool } from '@/lib/copilot/tools/server/knowledge/list-integrations' -import { knowledgeOperations } from '@/lib/knowledge/application/operations' - -const context = { - userId: 'person', - organizationId: 'org', - chatId: 'private-chat', - toolCallId: 'call', - requestMode: 'assistant', - copilotToolExecution: true, -} -beforeEach(() => { - vi.clearAllMocks() - m.authorizeChat.mockResolvedValue(undefined) - m.read.mockResolvedValue({ connections: [], available: [], nextCursor: null }) -}) -describe('list_integrations', () => { - it('binds the read to the authorized current person and private organization chat', async () => { - expect( - await listIntegrationsServerTool.execute({ connectorType: 'gmail', cursor: 'page' }, context) - ).toMatchObject({ success: true }) - expect(m.read).toHaveBeenCalledWith({ - principal: expect.objectContaining({ - subjectUserId: 'person', - organizationId: 'org', - resourceScope: { chatId: 'private-chat' }, - }), - input: { organizationId: 'org', connectorType: 'gmail', cursor: 'page' }, - }) - expect(m.authorizeChat).toHaveBeenCalledOnce() - }) - it.each([ - { organizationId: 'forged' }, - { userId: 'another' }, - { connectorType: 'x'.repeat(101) }, - { cursor: 'x'.repeat(1025) }, - ])('rejects forged scope and unbounded arguments', async (args) => { - expect(await listIntegrationsServerTool.execute(args, context)).toMatchObject({ - success: false, - }) - expect(m.read).not.toHaveBeenCalled() - }) - it.each([ - { ...context, workspaceId: 'workspace' }, - { ...context, requestMode: 'agent' }, - { ...context, chatId: undefined }, - { ...context, copilotToolExecution: false }, - ])('rejects untrusted or non-organization contexts', async (invalid) => { - expect(await listIntegrationsServerTool.execute({}, invalid)).toMatchObject({ success: false }) - expect(m.read).not.toHaveBeenCalled() - }) - it('checks revoked chat access before returning any account data', async () => { - m.authorizeChat.mockRejectedValue(new Error('revoked')) - expect(await listIntegrationsServerTool.execute({}, context)).toMatchObject({ success: false }) - expect(m.read).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts b/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts deleted file mode 100644 index eae98d2763a..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createLogger } from '@sim/logger' -import { z } from 'zod' -import { - executeCopilotOrganizationKnowledgeUseCase, - messageForCopilotKnowledgeError, - requireCopilotKnowledgeScope, -} from '@/lib/copilot/application/execute-knowledge-use-case' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' - -const logger = createLogger('ListSearchIntegrations') -const inputSchema = z - .object({ - connectorType: z.string().trim().min(1).max(100).optional(), - cursor: z.string().min(1).max(1024).optional(), - }) - .strict() - -export const listIntegrationsServerTool: BaseServerTool = { - name: 'list_integrations', - async execute(raw, context) { - try { - const scope = requireCopilotKnowledgeScope(context) - if (scope.kind !== 'organization') - throw new Error('Integration inventory requires organization Search') - const input = inputSchema.parse(raw) - const data = await executeCopilotOrganizationKnowledgeUseCase( - context, - listPersonalSearchIntegrations, - { - ...input, - organizationId: scope.organizationId, - } - ) - return { - success: true, - data, - message: - 'These are your current Search connections. Connected does not mean indexed. To offer a connection, emit the exact action or target inside a terminal tag, without a URL. Refresh this inventory after the user submits connection status. Follow nextCursor before claiming this list is complete.', - } - } catch (error) { - logger.error('Could not list personal Search integrations', { error }) - return { - success: false, - message: - error instanceof z.ZodError - ? 'Invalid integration inventory arguments' - : messageForCopilotKnowledgeError(error), - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts index 497eeadd309..91747f3629a 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts @@ -119,6 +119,31 @@ describe('Assistant retrieval tools', () => { }) }) + it.each([ + { searchSurface: undefined, expected: 'copilot' }, + { searchSurface: 'slack' as const, expected: 'slack' }, + ])( + 'attributes organization searches to trusted $expected provenance', + async ({ searchSurface, expected }) => { + const result = await searchWorkspaceServerTool.execute( + { query: 'policy', surface: 'api', searchSurface: 'api' }, + { + ...context, + workspaceId: undefined, + organizationId: 'org-1', + chatId: 'chat-1', + requestMode: 'assistant', + searchSurface, + } + ) + expect(result).toMatchObject({ success: true }) + expect(mocks.search).toHaveBeenCalledWith({ + principal: expect.objectContaining({ organizationId: 'org-1', subjectUserId: 'reader' }), + input: expect.objectContaining({ surface: expected, organizationId: 'org-1' }), + }) + } + ) + it('rejects a removed member or another private chat before reading documents', async () => { mocks.authorizeChat.mockRejectedValueOnce(new Error('Conversation not found')) const result = await readDocumentServerTool.execute( diff --git a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts b/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts index e5bc7664f7d..4fad2478865 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts @@ -53,7 +53,7 @@ export const searchWorkspaceServerTool: BaseServerTool = { query: projected.value, topK, filters: intersectWorkspaceSearchFilters(requestedFilters, context?.assistantSearch), - surface: 'copilot', + surface: context?.searchSurface ?? 'copilot', resultSecretRegistry: registry, signal: context?.abortSignal, } as const diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index e8506a24464..0c499a66e9e 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -44,7 +44,6 @@ import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/worksp import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { listIntegrationsServerTool } from '@/lib/copilot/tools/server/knowledge/list-integrations' import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { readDocumentServerTool, @@ -183,7 +182,6 @@ const baseServerToolRegistry: Record = { [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, - [listIntegrationsServerTool.name]: listIntegrationsServerTool, [searchWorkspaceServerTool.name]: searchWorkspaceServerTool, [readDocumentServerTool.name]: readDocumentServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, diff --git a/apps/sim/lib/copilot/tools/tool-activity.test.ts b/apps/sim/lib/copilot/tools/tool-activity.test.ts new file mode 100644 index 00000000000..8c2d90c8725 --- /dev/null +++ b/apps/sim/lib/copilot/tools/tool-activity.test.ts @@ -0,0 +1,166 @@ +/** + * @vitest-environment node + */ +import { isRecordLike } from '@sim/utils/object' +import { describe, expect, it } from 'vitest' +import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { getToolActivityLabel, TOOL_ACTIVITIES } from '@/lib/copilot/tools/tool-activity' +import { TOOL_ICONS } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' + +const visibleTools = Object.values(TOOL_CATALOG).filter( + (tool) => tool.route !== 'subagent' && !tool.hidden && !isToolHiddenInUi(tool.id) +) + +describe('tool activity catalog coverage', () => { + it('covers subagent icons under their dispatch and stream names', () => { + for (const tool of Object.values(TOOL_CATALOG)) { + if (tool.route !== 'subagent') continue + expect(Object.hasOwn(TOOL_ICONS, tool.id), tool.id).toBe(true) + if (tool.subagentId) { + expect(Object.hasOwn(TOOL_ICONS, tool.subagentId), tool.subagentId).toBe(true) + } + } + }) + + it.each(visibleTools)('explicitly describes $id and every declared operation', (tool) => { + expect(Object.hasOwn(TOOL_ACTIVITIES, tool.id), `${tool.id} summary`).toBe(true) + expect(Object.hasOwn(TOOL_ICONS, tool.id), `${tool.id} icon`).toBe(true) + const activity = TOOL_ACTIVITIES[tool.id] + const properties = isRecordLike(tool.parameters) ? tool.parameters.properties : undefined + if (!isRecordLike(properties)) return + for (const parameter of ['operation', 'action'] as const) { + const schema = properties[parameter] + if (!isRecordLike(schema) || !Array.isArray(schema.enum)) continue + expect(typeof activity, `${tool.id}.${parameter}`).toBe('object') + if (typeof activity === 'string') continue + expect(activity.parameter).toBe(parameter) + expect(Object.keys(activity.operations).sort()).toEqual([...schema.enum].sort()) + if (typeof schema.default === 'string') { + expect(activity.defaultOperation).toBe(schema.default) + } + for (const operation of schema.enum) { + expect(typeof operation).toBe('string') + expect(getToolActivityLabel(tool.id, { [parameter]: operation })).not.toMatch( + /^used (tools|the browser)$/ + ) + } + } + }) +}) + +describe('getToolActivityLabel', () => { + it.each([ + ['browser_go_back', 'navigated pages'], + ['browser_go_forward', 'navigated pages'], + ['browser_reload', 'navigated pages'], + ['browser_wait_for', 'waited'], + ['browser_list_sessions', 'checked signed-in sites'], + ['browser_hover', 'hovered over elements'], + ['browser_press_key', 'pressed keys'], + ['run_block', 'ran workflows'], + ['run_from_block', 'ran workflows'], + ['redeploy', 'deployed workflows'], + ['generate_image', 'generated images'], + ['generate_audio', 'generated audio'], + ['generate_video', 'generated video'], + ['create_empty_file', 'created files'], + ['cp', 'copied resources'], + ['mv', 'moved resources'], + ['rm', 'deleted resources'], + ['terminal_run', 'ran commands'], + ['terminal_read', 'read terminal output'], + ['terminal_input', 'sent terminal input'], + ])('describes %s as %s', (toolName, expected) => { + expect(getToolActivityLabel(toolName)).toBe(expected) + }) + + it.each([ + ['terminal', 'read', 'read terminal output'], + ['terminal', 'input', 'sent terminal input'], + ['terminal', 'kill', 'stopped commands'], + ['terminal', 'panes', 'listed terminal panes'], + ['search_knowledge_base', 'get', 'read knowledge bases'], + ['ffmpeg', 'probe', 'inspected media'], + ['ffmpeg', 'trim', 'edited media'], + ['query_user_table', 'query_rows', 'searched rows'], + ['table_rows', 'batch_insert_rows', 'added rows'], + ['table_rows', 'update_rows_by_filter', 'updated rows'], + ['table_rows', 'delete_rows_by_filter', 'deleted rows'], + ['table_manage', 'import_file', 'imported table data'], + ['table_views', 'get_view', 'read table views'], + ['table_views', 'update_view', 'edited table views'], + ['table_automations', 'list_workflow_outputs', 'read table automations'], + ['table_automations', 'run_column', 'ran table automations'], + ['table_automations', 'cancel_table_runs', 'stopped table runs'], + ['manage_knowledge_base', 'get', 'read knowledge bases'], + ['manage_knowledge_base', 'query', 'searched sources'], + ['manage_knowledge_base', 'update', 'updated knowledge bases'], + ['manage_mcp_connection', 'list', 'read connections'], + ['manage_mcp_connection', 'edit', 'edited connections'], + ['manage_sandbox', 'list', 'read sandboxes'], + ['manage_skill', 'list', 'read skills'], + ['manage_custom_tool', 'list', 'read custom tools'], + ['save_upload', 'import', 'imported workflows'], + ['save_upload', 'extract', 'extracted files'], + ])('describes %s %s accurately', (toolName, operation, expected) => { + expect(getToolActivityLabel(toolName, { operation })).toBe(expected) + }) + + it.each(['deploy_as_api', 'deploy_as_chat', 'deploy_as_mcp'])( + 'distinguishes deploying and undeploying with %s', + (toolName) => { + expect(getToolActivityLabel(toolName)).toBe('deployed workflows') + expect(getToolActivityLabel(toolName, { action: 'deploy' })).toBe('deployed workflows') + expect(getToolActivityLabel(toolName, { action: 'undeploy' })).toBe('undeployed workflows') + } + ) + + it('preserves default operations and distinguishes reversing published access', () => { + expect(getToolActivityLabel('save_upload')).toBe('saved files') + expect(getToolActivityLabel('share_file')).toBe('shared files') + expect(getToolActivityLabel('share_file', { action: 'unshare' })).toBe('stopped sharing files') + expect(getToolActivityLabel('publish_custom_block')).toBe('published custom blocks') + expect(getToolActivityLabel('publish_custom_block', { action: 'undeploy' })).toBe( + 'unpublished custom blocks' + ) + }) + + it.each([ + 'query_user_table', + 'table_manage', + 'table_rows', + 'table_columns', + 'table_automations', + 'table_enrichments', + ])('keeps legacy combined table operations consistent with %s', (toolName) => { + const activity = TOOL_ACTIVITIES[toolName] + expect(typeof activity).toBe('object') + if (typeof activity === 'string') return + for (const operation of Object.keys(activity.operations)) { + expect(getToolActivityLabel('user_table', { operation })).toBe( + getToolActivityLabel(toolName, { operation }) + ) + } + }) + + it.each(['future_operation', '', null, 4, [], {}, 'constructor', 'toString', '__proto__'])( + 'uses neutral fallbacks for invalid or unknown operations: %j', + (operation) => { + expect(getToolActivityLabel('terminal', { operation })).toBe('used the terminal') + expect(getToolActivityLabel('table_rows', { operation })).toBe('used tables') + expect(getToolActivityLabel('deploy_as_api', { action: operation })).toBe('used deployments') + } + ) + + it.each(['future_tool', '', 'constructor', 'toString', '__proto__'])( + 'keeps unknown/historical tool %s safe', + (toolName) => { + expect(getToolActivityLabel(toolName)).toBe('used tools') + } + ) + + it('keeps unknown browser calls visible', () => { + expect(getToolActivityLabel('browser_future_action')).toBe('used the browser') + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-activity.ts b/apps/sim/lib/copilot/tools/tool-activity.ts new file mode 100644 index 00000000000..7f4b367b5ab --- /dev/null +++ b/apps/sim/lib/copilot/tools/tool-activity.ts @@ -0,0 +1,394 @@ +interface OperationActivity { + label: string + parameter: 'operation' | 'action' + defaultOperation?: string + operations: Readonly> +} + +const DEPLOYMENT_ACTIVITY: OperationActivity = { + label: 'used deployments', + parameter: 'action', + defaultOperation: 'deploy', + operations: { deploy: 'deployed workflows', undeploy: 'undeployed workflows' }, +} + +const QUERY_USER_TABLE_OPERATIONS = { + get: 'read tables', + get_schema: 'read tables', + get_row: 'read rows', + query_rows: 'searched rows', +} as const + +const TABLE_MANAGE_OPERATIONS = { + create: 'created tables', + create_from_file: 'created tables', + import_file: 'imported table data', + rename: 'renamed tables', +} as const + +const TABLE_ROWS_OPERATIONS = { + insert_row: 'added rows', + batch_insert_rows: 'added rows', + update_row: 'updated rows', + batch_update_rows: 'updated rows', + delete_row: 'deleted rows', + batch_delete_rows: 'deleted rows', + update_rows_by_filter: 'updated rows', + delete_rows_by_filter: 'deleted rows', +} as const + +const TABLE_COLUMNS_OPERATIONS = { + add_column: 'edited table columns', + rename_column: 'edited table columns', + delete_column: 'edited table columns', + update_column: 'edited table columns', +} as const + +const TABLE_AUTOMATIONS_OPERATIONS = { + list_workflow_outputs: 'read table automations', + add_workflow_group: 'edited table automations', + update_workflow_group: 'edited table automations', + delete_workflow_group: 'edited table automations', + add_workflow_group_output: 'edited table automations', + delete_workflow_group_output: 'edited table automations', + run_column: 'ran table automations', + cancel_table_runs: 'stopped table runs', +} as const + +const TABLE_ENRICHMENTS_OPERATIONS = { + list_enrichments: 'read table enrichments', + add_enrichment: 'configured enrichments', +} as const + +/** Client-owned summaries; the executable tool registry stays outside the UI bundle. */ +export const TOOL_ACTIVITIES: Readonly> = { + apply_file_edit: 'edited files', + browser_click: 'clicked elements', + browser_click_at: 'clicked elements', + browser_close_tab: 'closed tabs', + browser_drag: 'dragged elements', + browser_extract: 'read pages', + browser_fill_form: 'filled forms', + browser_find: 'searched pages', + browser_go_back: 'navigated pages', + browser_go_forward: 'navigated pages', + browser_hover: 'hovered over elements', + browser_insert_text: 'entered text', + browser_list_downloads: 'listed downloads', + browser_list_sessions: 'checked signed-in sites', + browser_list_tabs: 'listed tabs', + browser_navigate: 'navigated pages', + browser_open_tab: 'opened tabs', + browser_open_url: 'navigated pages', + browser_press_key: 'pressed keys', + browser_read_text: 'read pages', + browser_reload: 'navigated pages', + browser_request_takeover: 'resumed browser control', + browser_screenshot: 'captured screenshots', + browser_scroll: 'scrolled pages', + browser_select_option: 'selected options', + browser_set_checked: 'updated selections', + browser_snapshot: 'read pages', + browser_switch_tab: 'switched tabs', + browser_type: 'entered text', + browser_wait_for: 'waited', + browser_zoom: { + label: 'adjusted page zoom', + parameter: 'action', + operations: { + in: 'adjusted page zoom', + out: 'adjusted page zoom', + reset: 'adjusted page zoom', + }, + }, + call_integration_tool: 'used integrations', + cancel_workflow_run: 'stopped workflow runs', + connect_slack_bot: 'connected integrations', + cp: 'copied resources', + create_empty_file: 'created files', + create_workflow: 'created workflows', + create_workspace_mcp_server: 'created MCP servers', + delete_workspace_mcp_server: 'deleted MCP servers', + deploy_as_api: DEPLOYMENT_ACTIVITY, + deploy_as_chat: DEPLOYMENT_ACTIVITY, + deploy_as_mcp: DEPLOYMENT_ACTIVITY, + diff_workflows: 'compared workflows', + download_file: 'downloaded files', + edit_workflow: 'edited workflows', + extract_doc_assets: 'extracted document assets', + ffmpeg: { + label: 'used media tools', + parameter: 'operation', + operations: { + overlay_audio: 'edited media', + mix_audio: 'edited media', + concat: 'edited media', + trim: 'edited media', + scale_pad: 'edited media', + overlay_image: 'edited media', + add_text: 'edited media', + fade: 'edited media', + extract_audio: 'edited media', + convert: 'edited media', + thumbnail: 'edited media', + probe: 'inspected media', + }, + }, + generate_api_key: 'created API keys', + generate_audio: 'generated audio', + generate_image: 'generated images', + generate_video: 'generated video', + get_block_outputs: 'read workflow outputs', + get_block_upstream_references: 'read workflows', + get_deployed_workflow_state: 'read deployments', + get_deployment_status: 'read deployments', + get_workflow_data: 'read workflows', + get_workflow_run_options: 'read workflows', + glob: 'found files', + grep: 'searched files', + interrupt_agent: 'stopped agents', + list_deployment_versions: 'read deployments', + list_integration_tools: 'read integration tools', + list_integrations: 'read integrations', + list_workspace_mcp_servers: 'read MCP servers', + load_deployment: 'loaded workflow versions', + manage_credential: { + label: 'managed credentials', + parameter: 'operation', + operations: { + rename: 'renamed credentials', + delete: 'deleted credentials', + }, + }, + manage_custom_tool: { + label: 'managed custom tools', + parameter: 'operation', + operations: { + add: 'created custom tools', + edit: 'edited custom tools', + delete: 'deleted custom tools', + list: 'read custom tools', + }, + }, + manage_knowledge_base: { + label: 'used knowledge bases', + parameter: 'operation', + operations: { + create: 'created knowledge bases', + get: 'read knowledge bases', + query: 'searched sources', + add_file: 'added source documents', + update: 'updated knowledge bases', + delete_document: 'deleted source documents', + update_document: 'updated source documents', + list_tags: 'read source tags', + create_tag: 'edited source tags', + update_tag: 'edited source tags', + delete_tag: 'edited source tags', + get_tag_usage: 'read source tags', + add_connector: 'created connections', + update_connector: 'edited connections', + delete_connector: 'deleted connections', + sync_connector: 'synced sources', + }, + }, + manage_mcp_connection: { + label: 'managed connections', + parameter: 'operation', + operations: { + add: 'created connections', + edit: 'edited connections', + delete: 'deleted connections', + list: 'read connections', + }, + }, + manage_sandbox: { + label: 'managed sandboxes', + parameter: 'operation', + operations: { + add: 'created sandboxes', + edit: 'edited sandboxes', + delete: 'deleted sandboxes', + list: 'read sandboxes', + }, + }, + manage_skill: { + label: 'managed skills', + parameter: 'operation', + operations: { + add: 'created skills', + edit: 'edited skills', + delete: 'deleted skills', + list: 'read skills', + }, + }, + mkdir: 'created folders', + mv: 'moved resources', + oauth_get_auth_link: 'prepared sign-in links', + oauth_request_access: 'requested access', + open_resource: 'opened resources', + prepare_file_edit: { + label: 'prepared file edits', + parameter: 'operation', + operations: { + append: 'prepared file edits', + update: 'prepared file edits', + patch: 'prepared file edits', + }, + }, + promote_to_live: 'deployed workflows', + publish_custom_block: { + label: 'managed custom blocks', + parameter: 'action', + defaultOperation: 'deploy', + operations: { + deploy: 'published custom blocks', + undeploy: 'unpublished custom blocks', + }, + }, + query_logs: 'read logs', + query_user_table: { + label: 'read tables', + parameter: 'operation', + operations: QUERY_USER_TABLE_OPERATIONS, + }, + read: 'read files', + read_document: 'read documents', + redeploy: 'deployed workflows', + restore_resource: 'restored resources', + rm: 'deleted resources', + run_block: 'ran workflows', + run_code: 'ran code', + run_enrichment: 'ran enrichments', + run_from_block: 'ran workflows', + run_function: 'ran code', + run_workflow: 'ran workflows', + run_workflow_until_block: 'ran workflows', + save_upload: { + label: 'used uploaded files', + parameter: 'operation', + defaultOperation: 'save', + operations: { + save: 'saved files', + import: 'imported workflows', + extract: 'extracted files', + }, + }, + search_docs: 'read documentation', + search_knowledge_base: { + label: 'searched sources', + parameter: 'operation', + operations: { + get: 'read knowledge bases', + query: 'searched sources', + list_tags: 'read source tags', + }, + }, + search_library_docs: 'read documentation', + search_workspace: 'searched the workspace', + set_block_enabled: 'edited workflows', + set_environment_variables: 'updated environment variables', + set_global_workflow_variables: 'updated workflow variables', + share_file: { + label: 'managed file sharing', + parameter: 'action', + defaultOperation: 'share', + operations: { + share: 'shared files', + unshare: 'stopped sharing files', + }, + }, + steer_agent: 'guided agents', + table_automations: { + label: 'used table automations', + parameter: 'operation', + operations: TABLE_AUTOMATIONS_OPERATIONS, + }, + table_columns: { + label: 'used table columns', + parameter: 'operation', + operations: TABLE_COLUMNS_OPERATIONS, + }, + table_enrichments: { + label: 'used table enrichments', + parameter: 'operation', + operations: TABLE_ENRICHMENTS_OPERATIONS, + }, + table_manage: { + label: 'used tables', + parameter: 'operation', + operations: TABLE_MANAGE_OPERATIONS, + }, + table_rows: { + label: 'used tables', + parameter: 'operation', + operations: TABLE_ROWS_OPERATIONS, + }, + table_views: { + label: 'used table views', + parameter: 'operation', + operations: { + list_views: 'read table views', + get_view: 'read table views', + create_view: 'edited table views', + update_view: 'edited table views', + delete_view: 'edited table views', + set_default_view: 'edited table views', + }, + }, + tail_agent: 'read agent progress', + terminal: { + label: 'used the terminal', + parameter: 'operation', + operations: { + run: 'ran commands', + read: 'read terminal output', + input: 'sent terminal input', + kill: 'stopped commands', + cwd: 'checked terminal locations', + list: 'listed terminals', + new: 'opened terminals', + switch: 'switched terminals', + close: 'closed terminals', + panes: 'listed terminal panes', + handoff: 'handed over terminal control', + }, + }, + terminal_cwd: 'checked terminal locations', + terminal_input: 'sent terminal input', + terminal_kill: 'stopped commands', + terminal_read: 'read terminal output', + terminal_run: 'ran commands', + update_deployment_version: 'updated deployment details', + update_workspace_mcp_server: 'updated MCP servers', + user_table: { + label: 'used tables', + parameter: 'operation', + operations: { + ...QUERY_USER_TABLE_OPERATIONS, + ...TABLE_MANAGE_OPERATIONS, + ...TABLE_ROWS_OPERATIONS, + ...TABLE_COLUMNS_OPERATIONS, + ...TABLE_AUTOMATIONS_OPERATIONS, + ...TABLE_ENRICHMENTS_OPERATIONS, + }, + }, + wait: 'waited', + wait_agents: 'waited', + web_crawl: 'read web pages', + web_fetch: 'read web pages', + web_scrape: 'read web pages', + web_search: 'searched the web', +} + +/** Unknown tools and operations retain a neutral summary for historical/custom calls. */ +export function getToolActivityLabel(toolName: string, params?: Record): string { + const activity = Object.hasOwn(TOOL_ACTIVITIES, toolName) ? TOOL_ACTIVITIES[toolName] : undefined + if (!activity) return toolName.startsWith('browser_') ? 'used the browser' : 'used tools' + if (typeof activity === 'string') return activity + const suppliedOperation = params?.[activity.parameter] + const operation = suppliedOperation === undefined ? activity.defaultOperation : suppliedOperation + return typeof operation === 'string' && Object.hasOwn(activity.operations, operation) + ? activity.operations[operation] + : activity.label +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 89e15b8c3fd..9eae8f49e93 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -795,3 +795,24 @@ describe('resource-naming titles', () => { ) }) }) + +/** Skipped live calls and interrupted history must not retain running titles. */ +describe('getToolStatusDisplayTitle for skipped and interrupted calls', () => { + it.each([ + ['skipped', 'Skipped running checks'], + ['interrupted', 'Stopped running checks'], + ] as const)('projects %s titles once', (status, expected) => { + expect(getToolStatusDisplayTitle('Running checks', status)).toBe(expected) + expect(getToolStatusDisplayTitle(expected, status)).toBe(expected) + }) + + it('preserves titles already describing a terminal outcome', () => { + expect(getToolStatusDisplayTitle('Skipped reading notes', 'interrupted')).toBe( + 'Skipped reading notes' + ) + expect(getToolStatusDisplayTitle('Attempted to run checks', 'skipped')).toBe( + 'Attempted to run checks' + ) + expect(getToolStatusDisplayTitle('Checks', 'skipped')).toBe('Skipped: Checks') + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 166840b8c22..da4f01ddc7a 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -565,7 +565,6 @@ const TOOL_TITLES: Record = { edit_workflow: 'Editing workflow', manage_knowledge_base: 'Managing knowledge base', search_knowledge_base: 'Searching knowledge base', - list_integrations: 'Checking your integrations', search_workspace: 'Searching documents', read_document: 'Reading document', open_resource: 'Opening resource', @@ -1439,6 +1438,16 @@ function statesTerminalOutcome(title: string): boolean { return TERMINAL_TITLE_PREFIXES.has(firstWordOf(title).replace(/:$/, '')) } +/** Apply one terminal outcome prefix while preserving already-resolved titles. */ +function getToolOutcomeTitle(title: string, outcome: 'Failed' | 'Stopped' | 'Skipped'): string { + if (statesTerminalOutcome(title)) return title + const firstWord = firstWordOf(title) + if (COMPLETED_VERB_REWRITES[firstWord]) { + return `${outcome} ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` + } + return `${outcome}: ${title}` +} + /** * Rewrite a resolved display title for a FAILED tool call. A gerund title * becomes "Failed …" ("Searching for X" → "Failed searching for X"); @@ -1446,29 +1455,19 @@ function statesTerminalOutcome(title: string): boolean { * its present-tense activity title verbatim and read as still running. */ export function getToolFailedTitle(title: string): string { - if (statesTerminalOutcome(title)) return title - const firstWord = firstWordOf(title) - if (COMPLETED_VERB_REWRITES[firstWord]) { - return `Failed ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` - } - return `Failed: ${title}` + return getToolOutcomeTitle(title, 'Failed') } /** Rewrite a resolved display title for a CANCELLED tool call ("Stopped …"). */ export function getToolStoppedTitle(title: string): string { - if (statesTerminalOutcome(title)) return title - const firstWord = firstWordOf(title) - if (COMPLETED_VERB_REWRITES[firstWord]) { - return `Stopped ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` - } - return `Stopped: ${title}` + return getToolOutcomeTitle(title, 'Stopped') } /** * Resolve the final title for a tool status at a rendering boundary. Persisted * and live snapshots intentionally keep the present-tense activity title so a * RUNNING row remains truthful; terminal states project a tense that says the - * work is over — completed (past tense), failed, or stopped. + * work is over — completed (past tense), failed, stopped, or skipped. */ export function getToolStatusDisplayTitle( title: string, @@ -1480,6 +1479,9 @@ export function getToolStatusDisplayTitle( } if (status === 'success') return getToolCompletedTitle(title) ?? title if (status === 'error' || status === 'rejected') return getToolFailedTitle(title) - if (status === 'cancelled' || status === 'aborted') return getToolStoppedTitle(title) + if (status === 'cancelled' || status === 'aborted' || status === 'interrupted') { + return getToolStoppedTitle(title) + } + if (status === 'skipped') return getToolOutcomeTitle(title, 'Skipped') return title } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ae0f3ddbc71..a7bee9c48b4 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -547,6 +547,8 @@ export const env = createEnv({ DROPBOX_CLIENT_ID: z.string().optional(), // Dropbox OAuth client ID DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID + SLACK_SEARCH_APP_ID: z.string().optional(), + SLACK_SEARCH_SHARED_APP: z.boolean().optional(), SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger) SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Request app_mentions:read, assistant:write, im:history — only where the Slack app is approved for them diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 1ca79162f80..c65f0111c8e 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 = { + 'slack-search-shared-app': { + description: + 'Enable the official shared Slack app for existing Search customers. Global on/off only.', + fallback: 'SLACK_SEARCH_SHARED_APP', + }, 'trigger-eu-region': { description: 'Route Trigger.dev runs to eu-central-1 instead of the default us-east-1. Global on/off ' + diff --git a/apps/sim/lib/core/config/redis.test.ts b/apps/sim/lib/core/config/redis.test.ts index 5314c2095fb..bf02a57e48d 100644 --- a/apps/sim/lib/core/config/redis.test.ts +++ b/apps/sim/lib/core/config/redis.test.ts @@ -38,6 +38,7 @@ vi.mock('@sim/logger', () => ({ logger: mockLogger, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: () => {}, })) vi.mock('ioredis', () => ({ diff --git a/apps/sim/lib/core/utils/uuid-v5.test.ts b/apps/sim/lib/core/utils/uuid-v5.test.ts new file mode 100644 index 00000000000..d7bfa33b1af --- /dev/null +++ b/apps/sim/lib/core/utils/uuid-v5.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node + */ +import { isValidUuid } from '@sim/utils/id' +import { describe, expect, it } from 'vitest' +import { uuidV5 } from '@/lib/core/utils/uuid-v5' + +const DNS_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + +describe('uuidV5', () => { + it('matches the RFC 4122 reference vector', () => { + expect(uuidV5('python.org', DNS_NAMESPACE)).toBe('886313e1-3b8a-5372-9b90-0c9aee199e5d') + }) + + it('is deterministic and namespace-scoped', () => { + const a = uuidV5('customer-456', DNS_NAMESPACE) + expect(uuidV5('customer-456', DNS_NAMESPACE)).toBe(a) + expect(uuidV5('customer-457', DNS_NAMESPACE)).not.toBe(a) + expect(uuidV5('customer-456', '00000000-0000-0000-0000-000000000000')).not.toBe(a) + expect(isValidUuid(a)).toBe(true) + expect(a[14]).toBe('5') + }) +}) diff --git a/apps/sim/lib/core/utils/uuid-v5.ts b/apps/sim/lib/core/utils/uuid-v5.ts new file mode 100644 index 00000000000..9dda65176d4 --- /dev/null +++ b/apps/sim/lib/core/utils/uuid-v5.ts @@ -0,0 +1,26 @@ +import { createHash } from 'node:crypto' + +function uuidToBytes(uuid: string): Buffer { + return Buffer.from(uuid.replace(/-/g, ''), 'hex') +} + +/** + * Deterministic UUIDv5 (SHA-1) of `name` within `namespace`, per RFC 4122 §4.3. + * The same inputs always yield the same UUID, which is how callers derive a + * stable identity from a caller-chosen string. + * + * SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for + * deterministic id derivation, never for secrecy or integrity. Every caller + * pins its own namespace constant; changing a namespace re-keys every id that + * caller ever derived. + */ +export function uuidV5(name: string, namespace: string): string { + const hash = createHash('sha1') + hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm] + hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm] + const bytes = hash.digest().subarray(0, 16) + bytes[6] = (bytes[6] & 0x0f) | 0x50 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/apps/sim/lib/core/utils/with-route-handler.test.ts b/apps/sim/lib/core/utils/with-route-handler.test.ts index 0ac7b9cd6d6..c87415fd7f8 100644 --- a/apps/sim/lib/core/utils/with-route-handler.test.ts +++ b/apps/sim/lib/core/utils/with-route-handler.test.ts @@ -18,6 +18,49 @@ class TestHttpError extends HttpError { } describe('withRouteHandler', () => { + it('carries the workflow call chain into the request context', async () => { + const seen: unknown[] = [] + vi.mocked(loggerMock.runWithRequestContext).mockImplementationOnce((context, fn) => { + seen.push(context) + return fn() + }) + const handler = withRouteHandler(async () => NextResponse.json({ ok: true })) + + await handler( + new NextRequest('http://localhost/api/test', { headers: { 'x-sim-via': 'wf-1, wf-2' } }), + undefined + ) + + expect(seen[0]).toEqual(expect.objectContaining({ callChain: ['wf-1', 'wf-2'] })) + }) + + it('resolves the sending client into the request context for logs and analytics', async () => { + const seen: unknown[] = [] + vi.mocked(loggerMock.runWithRequestContext).mockImplementationOnce((context, fn) => { + seen.push(context) + return fn() + }) + const handler = withRouteHandler(async () => NextResponse.json({ ok: true })) + + await handler( + new NextRequest('http://localhost/api/test', { + headers: { 'x-sim-client-info': 'cli/2.1.2; node/22.14.0; agent/claude-code' }, + }), + undefined + ) + + expect(seen[0]).toEqual( + expect.objectContaining({ + client: expect.objectContaining({ + surface: 'cli', + version: '2.1.2', + agent: 'claude-code', + source: 'header', + }), + }) + ) + }) + it('classifies errors after a client disconnect without using the unhandled fallback', async () => { const routeHandlerLogger = vi.mocked(loggerMock.createLogger).mock.results[ vi.mocked(loggerMock.createLogger).mock.calls.findIndex(([name]) => name === 'RouteHandler') diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index eb6c91d1c96..e25de9a486a 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -1,10 +1,13 @@ import { createLogger, runWithRequestContext } from '@sim/logger' +import { resolveClientInfo } from '@sim/utils/client-info' import { describeError, findCause, getErrorMessage, redactBoundParameters } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { hasExternalApiCredentials } from '@/lib/api/server/credential-headers' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' const logger = createLogger('RouteHandler') @@ -87,6 +90,29 @@ function traceIdFromTraceparent(header: string | null | undefined): string | und return match[1] } +/** + * Which official client sent the request, resolved once here so every log line + * and analytics event in the request carries it. Attribution only: the value + * is caller-controlled and never feeds authorization. + */ +function clientInfoFor(request: NextRequest) { + const headers = request?.headers + if (!headers?.get) return undefined + return resolveClientInfo(headers, { hasExternalCredentials: hasExternalApiCredentials(headers) }) +} + +/** + * The workflow call chain the request arrived with, so a request one workflow + * makes to run another is attributed to the workflow that made it. Read here, + * not only in the execute routes that enforce its depth, because the events a + * nested run emits should know they were nested. Bounded by the same cap the + * execute routes apply; a chain past it is refused there and truncated here. + */ +function callChainFor(request: NextRequest): readonly string[] | undefined { + const chain = parseCallChain(request?.headers?.get?.(SIM_VIA_HEADER)) + return chain.length > 0 ? chain.slice(0, MAX_CALL_CHAIN_DEPTH) : undefined +} + /** * What a wrapped error hides: a query failure from the database client carries * the driver's reason and the Postgres code on its cause, and only the outer @@ -134,8 +160,16 @@ export function withRouteHandler( const path = request?.nextUrl?.pathname ?? new URL(request?.url ?? '/', 'http://localhost').pathname const traceId = traceIdFromTraceparent(request?.headers?.get?.('traceparent')) - - return runWithRequestContext({ requestId, method, path, traceId }, async () => { + const requestContext = { + requestId, + method, + path, + traceId, + client: clientInfoFor(request), + callChain: callChainFor(request), + } + + return runWithRequestContext(requestContext, async () => { let response: NextResponse | Response try { response = await withPermissionGroupScope(() => handler(request, context)) diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts index f86ea9f7790..32c4897be29 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.test.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ setup: vi.fn(), write: vi.fn(), policy: vi.fn(), + accountsGroup: vi.fn(), + invite: vi.fn(), })) vi.mock('@/lib/credential-groups/scoped-availability', () => ({ isScopedCredentialGroupsAvailable: mocks.available, @@ -25,14 +27,17 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ })) vi.mock('@/lib/credential-groups/service', () => ({ ensureWorkspaceAccountsGroup: vi.fn(), - getOrganizationAccountsGroup: vi.fn(), + getOrganizationAccountsGroup: mocks.accountsGroup, updateCredentialGroup: vi.fn(), })) vi.mock('@/lib/credential-groups/provider-availability', () => ({ listConfiguredCredentialGroupProviders: vi.fn(), })) +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: vi.fn().mockResolvedValue(true), +})) vi.mock('@/lib/credential-groups/self-enrollment', () => ({ - createViewerCredentialGroupEnrollment: vi.fn(), + createViewerCredentialGroupEnrollment: mocks.invite, })) vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy, @@ -40,10 +45,15 @@ vi.mock('@/lib/resource-policies/repository', () => ({ ResourcePolicyRevisionConflictError: class extends Error {}, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getOrganizationAccountWorkspaceAccess, updateOrganizationAccountWorkspaceAccess, } from '@/lib/credential-groups/application/organization-access' +import { + getOrganizationAccountsSettings, + startOrganizationAccountConnection, +} from '@/lib/credential-groups/application/organization-accounts' import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { ResourcePolicyRevisionConflictError } from '@/lib/resource-policies/repository' @@ -84,6 +94,73 @@ describe('organization workspace sharing administration', () => { } ) + it('returns only the acting member account metadata for the canonical organization group, even when providers are disabled', async () => { + queueTableRows(schemaMock.member, [{ role: 'member' }]) + mocks.accountsGroup.mockResolvedValue({ id: 'group-1', status: 'disabled', options: [] }) + const account = { + credentialId: 'credential-1', + displayName: 'My GitHub', + providerId: 'github-repositories', + groupId: 'group-1', + optionId: 'option-1', + status: 'needs_reauth', + } + queueTableRows(schemaMock.credential, [account]) + const result = await getOrganizationAccountsSettings.execute({ + principal, + input: { organizationId: 'org-1' }, + }) + expect(result.viewerAccounts).toEqual([account]) + expect(result.canManage).toBe(false) + expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroup.id, 'group-1') + expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'admin-user') + expect(eq).toHaveBeenCalledWith(schemaMock.credential.organizationId, 'org-1') + }) + + it('starts an active account through the existing enrollment OAuth boundary', async () => { + queueTableRows(schemaMock.member, [{ role: 'member' }]) + mocks.accountsGroup.mockResolvedValue({ + id: 'group-1', + status: 'active', + options: [{ id: 'option-1', status: 'active' }], + }) + mocks.invite.mockResolvedValue({ + invitationLink: 'https://sim.test/credential-groups/enroll/fixture-token', + }) + await expect( + startOrganizationAccountConnection.execute({ + principal, + input: { organizationId: 'org-1', optionId: 'option-1' }, + }) + ).resolves.toEqual({ + invitationLink: + 'https://sim.test/credential-groups/enroll/fixture-token?optionId=option-1&returnTo=search', + authorizationUrl: + 'https://sim.test/api/credential-groups/enroll/fixture-token/oauth/option-1?returnTo=search', + }) + expect(mocks.invite).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + userId: 'admin-user', + credentialGroupId: 'group-1', + }) + }) + + it('does not issue a direct authorization link when enrollment access was revoked', async () => { + queueTableRows(schemaMock.member, [{ role: 'member' }]) + mocks.accountsGroup.mockResolvedValue({ + id: 'group-1', + status: 'active', + options: [{ id: 'option-1', status: 'active' }], + }) + mocks.invite.mockRejectedValue(new OrchestrationError('forbidden', 'Enrollment revoked')) + await expect( + startOrganizationAccountConnection.execute({ + principal, + input: { organizationId: 'org-1', optionId: 'option-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + }) + it('uses the routed org and checks every workspace before granting access', async () => { queueTableRows(schemaMock.member, [{ role: 'admin' }]) queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }]) diff --git a/apps/sim/lib/credential-groups/application/organization-accounts.ts b/apps/sim/lib/credential-groups/application/organization-accounts.ts index ccf1782f170..2eabfa67f15 100644 --- a/apps/sim/lib/credential-groups/application/organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/organization-accounts.ts @@ -1,4 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { credentialGroup as credentialGroupTable } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import type { OperationUseCase } from '@/lib/core/application/operation' import { authorizeOrganizationOperation, @@ -11,6 +13,7 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import { validateUpdateCredentialGroupInput } from '@/lib/credential-groups/application/validation' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { createCredentialGroupOAuthStartUrl } from '@/lib/credential-groups/enrollment-links' import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' import { ManagedMcpConnectorError } from '@/lib/credential-groups/managed-mcp-service' import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' @@ -26,6 +29,7 @@ import type { CredentialGroupOptionInput, UpdateCredentialGroupInput, } from '@/lib/credential-groups/types' +import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' export const organizationAccountOperations = { @@ -129,8 +133,16 @@ export function defineOrganizationAccountsUseCase< export const getOrganizationAccountsSettings = defineOrganizationAccountsUseCase({ operation: organizationAccountOperations.read, async execute({ context }) { + const credentialGroup = await getOrganizationAccountsGroup(context.organizationId) return { - credentialGroup: await getOrganizationAccountsGroup(context.organizationId), + credentialGroup, + viewerAccounts: credentialGroup + ? await listViewerOrganizationAccounts({ + organizationId: context.organizationId, + userId: context.userId, + matching: eq(credentialGroupTable.id, credentialGroup.id), + }) + : [], availableProviders: listConfiguredCredentialGroupProviders(), canManage: context.role === 'owner' || context.role === 'admin', indexingAvailable: await isKnowledgeMemberAccessAvailable({ @@ -219,6 +231,13 @@ export const startOrganizationAccountConnection = defineOrganizationAccountsUseC const url = new URL(invitationLink) url.searchParams.set('optionId', input.optionId) url.searchParams.set('returnTo', 'search') - return { invitationLink: url.toString() } + return { + invitationLink: url.toString(), + authorizationUrl: createCredentialGroupOAuthStartUrl({ + invitationLink, + optionId: input.optionId, + returnTo: 'search', + }), + } }, }) diff --git a/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts b/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts index 867ad51c839..df7d9fe70e3 100644 --- a/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts +++ b/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts @@ -54,7 +54,9 @@ describe('personal organization contributions', () => { vi.clearAllMocks() resetDbChainMock() mocks.available.mockResolvedValue(true) - mocks.invite.mockResolvedValue({ invitationLink: 'https://sim.test/enroll/token' }) + mocks.invite.mockResolvedValue({ + invitationLink: 'https://sim.test/credential-groups/enroll/fixture-token', + }) }) it('lists only the stable signed-in identity without requiring org membership', async () => { @@ -79,9 +81,21 @@ describe('personal organization contributions', () => { const url = new URL(result.invitationLink) expect(url.searchParams.get('optionId')).toBe('gmail-option') expect(url.searchParams.get('returnTo')).toBe('accounts') + expect(result.authorizationUrl).toBe( + 'https://sim.test/api/credential-groups/enroll/fixture-token/oauth/gmail-option?returnTo=accounts' + ) expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) }) + it('keeps the enrollment page for an MCP contribution', async () => { + queueTableRows(schemaMock.credential, [row]) + await expect( + reconnectPersonalOrganizationAccount.execute({ principal, input }) + ).resolves.toEqual({ + invitationLink: 'https://sim.test/credential-groups/enroll/fixture-token', + }) + }) + it('refuses disconnect of another contributor’s account', async () => { queueTableRows(schemaMock.credential, []) await expect( diff --git a/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts index ab7af64f16a..afd6096403a 100644 --- a/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts @@ -14,6 +14,7 @@ import { and, asc, eq, gt, inArray, isNotNull } from 'drizzle-orm' import { defineOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { sameResourceScopeCondition } from '@/lib/core/resource-scope.server' +import { createCredentialGroupOAuthStartUrl } from '@/lib/credential-groups/enrollment-links' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' @@ -152,7 +153,18 @@ export const reconnectPersonalOrganizationAccount = defineAuthorizedCredentialUs url.searchParams.set('optionId', account.optionId) url.searchParams.set('returnTo', 'accounts') } - return { invitationLink: url.toString() } + return { + invitationLink: url.toString(), + ...(account.type === 'managed_oauth' && account.optionId + ? { + authorizationUrl: createCredentialGroupOAuthStartUrl({ + invitationLink, + optionId: account.optionId, + returnTo: 'accounts', + }), + } + : {}), + } }, }) diff --git a/apps/sim/lib/credential-groups/enrollment-links.test.ts b/apps/sim/lib/credential-groups/enrollment-links.test.ts new file mode 100644 index 00000000000..69adab606af --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollment-links.test.ts @@ -0,0 +1,51 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { createCredentialGroupOAuthStartUrl } from '@/lib/credential-groups/enrollment-links' + +describe('credential group OAuth start links', () => { + it.each(['https://staging.sim.test', 'http://localhost:3015'])( + 'preserves the generated %s origin and encodes both path identifiers once', + (origin) => { + const token = 'fixture/token ?+%' + const optionId = 'option/with ?+%' + const url = new URL( + createCredentialGroupOAuthStartUrl({ + invitationLink: `${origin}/credential-groups/enroll/${encodeURIComponent(token)}?optionId=old&returnTo=accounts#old`, + optionId, + returnTo: 'search', + }) + ) + expect(url.origin).toBe(origin) + expect(url.pathname).toBe( + `/api/credential-groups/enroll/${encodeURIComponent(token)}/oauth/${encodeURIComponent(optionId)}` + ) + expect(url.search).toBe('?returnTo=search') + expect(url.hash).toBe('') + } + ) + + it('returns reconnects to account settings', () => { + expect( + createCredentialGroupOAuthStartUrl({ + invitationLink: 'https://sim.test/credential-groups/enroll/fixture-token', + optionId: 'option-1', + returnTo: 'accounts', + }) + ).toBe( + 'https://sim.test/api/credential-groups/enroll/fixture-token/oauth/option-1?returnTo=accounts' + ) + }) + + it.each(['/enroll/token', '/credential-groups/enroll/', '/credential-groups/enroll/token/extra'])( + 'rejects a noncanonical enrollment path %s', + (path) => { + expect(() => + createCredentialGroupOAuthStartUrl({ + invitationLink: `https://sim.test${path}`, + optionId: 'option-1', + returnTo: 'search', + }) + ).toThrow('Invalid credential group enrollment link') + } + ) +}) diff --git a/apps/sim/lib/credential-groups/enrollment-links.ts b/apps/sim/lib/credential-groups/enrollment-links.ts new file mode 100644 index 00000000000..1d7c76e2214 --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollment-links.ts @@ -0,0 +1,16 @@ +/** Starts OAuth through the existing enrollment boundary, which owns authentication and PKCE. */ +export function createCredentialGroupOAuthStartUrl(input: { + invitationLink: string + optionId: string + returnTo: 'search' | 'accounts' +}): string { + const invitation = new URL(input.invitationLink) + const token = invitation.pathname.match(/^\/credential-groups\/enroll\/([^/]+)$/)?.[1] + if (!token) throw new Error('Invalid credential group enrollment link') + const url = new URL( + `/api/credential-groups/enroll/${encodeURIComponent(decodeURIComponent(token))}/oauth/${encodeURIComponent(input.optionId)}`, + invitation.origin + ) + url.searchParams.set('returnTo', input.returnTo) + return url.toString() +} diff --git a/apps/sim/lib/credential-groups/limits.ts b/apps/sim/lib/credential-groups/limits.ts index cad8c92b28b..60963f14139 100644 --- a/apps/sim/lib/credential-groups/limits.ts +++ b/apps/sim/lib/credential-groups/limits.ts @@ -1,4 +1,5 @@ export const CREDENTIAL_GROUP_MCP_SERVER_LIMIT = 50 +export const ORGANIZATION_VIEWER_ACCOUNT_LIMIT = 100 export const ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT = 1000 export const CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT = 50 export const CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT = 500 diff --git a/apps/sim/lib/credential-groups/oauth-completion.ts b/apps/sim/lib/credential-groups/oauth-completion.ts index 31225358b61..1c732bdfc8c 100644 --- a/apps/sim/lib/credential-groups/oauth-completion.ts +++ b/apps/sim/lib/credential-groups/oauth-completion.ts @@ -4,9 +4,15 @@ export const CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES = { expired: 'This connection attempt expired. Try connecting your account again.', denied: 'Authorization was canceled. Try connecting your account again.', account_mismatch: 'Choose the account matching your Sim email address.', + github_email_mismatch: + 'In GitHub Settings → Emails, add and verify the email address used for this Sim connection, then try again. A verified secondary email is supported.', + github_email_access_denied: + 'GitHub did not allow access to your email addresses. Ask an admin to check that the GitHub App has Email addresses: Read-only permission, then authorize the app again.', permissions_required: 'All requested permissions are required to connect this account.', configuration_changed: 'The connection settings changed. Try connecting your account again.', - rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + rate_limited: 'Authorization is being rate limited. Wait a few minutes and try again.', + provider_unavailable: + 'The account provider could not verify your account right now. Try connecting again in a few minutes.', unavailable: 'This connection is unavailable. Try connecting your account again.', failed: 'Account authorization did not complete. Try connecting your account again.', } as const diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts index edd588c1f37..97abfbb9663 100644 --- a/apps/sim/lib/credential-groups/oauth-state.ts +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -44,7 +44,7 @@ interface StoredCredentialGroupOAuthAttempt { completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' + returnTo?: 'search' | 'accounts' | 'github-installation' nonceHash: string encryptedCodeVerifier?: string encryptedInvitationToken: string @@ -69,7 +69,7 @@ export interface CredentialGroupOAuthAttempt { completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' + returnTo?: 'search' | 'accounts' | 'github-installation' codeVerifier?: string invitationToken: string createdAt: number @@ -91,7 +91,7 @@ interface CreateCredentialGroupOAuthAttemptParams { completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' + returnTo?: 'search' | 'accounts' | 'github-installation' codeVerifier?: string invitationToken: string } @@ -147,7 +147,11 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt credentialGroupConnectionIntentSchema.safeParse(candidate.connectionIntent).success) && (candidate.returnTo === undefined || candidate.returnTo === 'search' || - candidate.returnTo === 'accounts') && + candidate.returnTo === 'accounts' || + (candidate.returnTo === 'github-installation' && + candidate.provider === 'github-repositories' && + typeof candidate.organizationId === 'string' && + typeof candidate.completionId === 'string')) && typeof candidate.nonceHash === 'string' && (candidate.encryptedCodeVerifier === undefined || typeof candidate.encryptedCodeVerifier === 'string') && @@ -166,6 +170,12 @@ export async function createCredentialGroupOAuthAttempt( ) { throw new Error('OAuth completion requires a valid correlation ID and completion redirect') } + if ( + params.returnTo === 'github-installation' && + (params.provider !== 'github-repositories' || !params.organizationId || !params.completionId) + ) { + throw new Error('GitHub installation OAuth requires an organization-bound setup attempt') + } if (params.connectionIntent) credentialGroupConnectionIntentSchema.parse(params.connectionIntent) const redis = requireRedis() const state = `${OAUTH_ATTEMPT_STATE_PREFIX}${generateId()}` diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index b905f373604..6699f6a7456 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -116,7 +116,7 @@ export async function startCredentialGroupOAuth( completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' + returnTo?: 'search' | 'accounts' | 'github-installation' } = {} ): Promise { if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() diff --git a/apps/sim/lib/credential-groups/provider-adapter.ts b/apps/sim/lib/credential-groups/provider-adapter.ts index 30cd9b36357..897bcf9282b 100644 --- a/apps/sim/lib/credential-groups/provider-adapter.ts +++ b/apps/sim/lib/credential-groups/provider-adapter.ts @@ -5,6 +5,7 @@ import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth- import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import type { DbOrTx } from '@/lib/db/types' import type { RefreshTokenResult } from '@/lib/oauth' +import type { OAuthIdentityVerificationError } from '@/lib/oauth/identity-error' export interface CredentialGroupProviderPolicy { provider: CredentialGroupProvider @@ -83,7 +84,8 @@ export class CredentialGroupProviderConfigurationError extends Error { export class CredentialGroupOAuthError extends Error { constructor( message: string, - readonly statusCode: 400 | 401 | 403 | 404 | 409 | 502 | 503 + readonly statusCode: 400 | 401 | 403 | 404 | 409 | 502 | 503, + readonly identityFailure?: OAuthIdentityVerificationError ) { super(message) this.name = 'CredentialGroupOAuthError' diff --git a/apps/sim/lib/credential-groups/provider-configuration.ts b/apps/sim/lib/credential-groups/provider-configuration.ts index 290a38a2fe6..13249edb58c 100644 --- a/apps/sim/lib/credential-groups/provider-configuration.ts +++ b/apps/sim/lib/credential-groups/provider-configuration.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' -import { credentialGroup, slackApp } from '@sim/db/schema' +import { credentialGroup, slackApp, slackSearchInstallation } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, isNull, or, sql } from 'drizzle-orm' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import type { DbOrTx } from '@/lib/db/types' +import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE = 'credential-group-provider-configuration' as const @@ -148,12 +149,30 @@ async function resolveSlackConfiguration( .where( and( eq(slackApp.id, configuration.appId), - eq(slackApp.organizationId, params.organizationId), - eq(slackApp.kind, 'custom') + or( + and(eq(slackApp.organizationId, params.organizationId), eq(slackApp.kind, 'custom')), + and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) + ) ) ) .limit(1) if (!app) throw new Error('Organization Slack app configuration is missing') + if (app.kind === 'shared') { + await requireSlackSearchAppAvailable(app.id) + const [installation] = await (params.executor ?? db) + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.slackAppId, app.id), + eq(slackSearchInstallation.organizationId, params.organizationId), + eq(slackSearchInstallation.teamId, configuration.teamId), + eq(slackSearchInstallation.enabled, true) + ) + ) + .limit(1) + if (!installation) throw new Error('The shared Slack installation is disabled or removed') + } const { decrypted: clientSecret } = await decryptSecret(app.encryptedClientSecret) return { appId: app.id, diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index 79f2e428378..80ca93294ac 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -244,22 +244,26 @@ export async function getCredentialGroup( export function ensureWorkspaceAccountsGroup( scope: Extract, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise export function ensureWorkspaceAccountsGroup( workspaceId: string, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise export function ensureWorkspaceAccountsGroup( scope: ResourceScope, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise export async function ensureWorkspaceAccountsGroup( scopeInput: string | ResourceScope, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise { const scope = credentialGroupScope(scopeInput) if (option?.provider === 'slack') { @@ -267,7 +271,7 @@ export async function ensureWorkspaceAccountsGroup( } const preparedOption = option ? await buildOption(scope, { ...option, required: false }) : null let wasCreated = false - const row = await db.transaction(async (tx) => { + const provision = async (tx: DbOrTx) => { await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`search-accounts:${resourceScopeKey(scope)}`}, 0))` ) @@ -307,7 +311,7 @@ export async function ensureWorkspaceAccountsGroup( throw new OrchestrationError( 'validation', scope.kind === 'organization' - ? `Refresh ${preparedOption.label} in Sources > More > Refresh connection settings, or Connected accounts > Providers > Update configurations when Search is disabled` + ? `Refresh ${preparedOption.label} in Sources > More > Update sign-in settings, or Connected accounts > Providers > Update configurations when Search is disabled` : `Update ${preparedOption.label} in Connected accounts before connecting this source` ) } @@ -377,9 +381,10 @@ export async function ensureWorkspaceAccountsGroup( ) wasCreated = true return created - }) + } + const row = executor ? await provision(executor) : await db.transaction(provision) return { - ...(await toCredentialGroup(row, await listLinkedMcpServers(row.id))), + ...(await toCredentialGroup(row, await listLinkedMcpServers(row.id, executor))), created: wasCreated, } } diff --git a/apps/sim/lib/credential-groups/shared-slack-app.ts b/apps/sim/lib/credential-groups/shared-slack-app.ts new file mode 100644 index 00000000000..ce6fd46bf57 --- /dev/null +++ b/apps/sim/lib/credential-groups/shared-slack-app.ts @@ -0,0 +1,89 @@ +import { credentialGroup } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { + decryptCredentialGroupProviderConfiguration, + encryptCredentialGroupProviderConfiguration, +} from '@/lib/credential-groups/provider-configuration' +import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' +import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { DbOrTx } from '@/lib/db/types' + +/** Configures personal consent atomically with the authorized admin's bot installation. */ +export async function configureSharedSlackMemberApp( + tx: DbOrTx, + input: { + organizationId: string + userId: string + appId: string + teamId: string + } +) { + const container = await ensureWorkspaceAccountsGroup( + { kind: 'organization', organizationId: input.organizationId }, + input.userId, + undefined, + tx + ) + const [group] = await tx + .select() + .from(credentialGroup) + .where(eq(credentialGroup.id, container.id)) + .for('update') + .limit(1) + if (!group) throw new Error('Organization accounts configuration disappeared') + const configuration = await decryptCredentialGroupProviderConfiguration( + group.encryptedProviderConfiguration + ) + if ( + configuration.slack && + (configuration.slack.appId !== input.appId || configuration.slack.teamId !== input.teamId) + ) + throw new OrchestrationError( + 'conflict', + 'Remove the previous Slack source configuration before installing another app' + ) + const existing = group.options.find((option) => option.provider === 'slack') + const requiredScopes = [...SLACK_SEARCH_USER_SCOPES] + const authorizationAppId = `slack:${input.appId}:${input.teamId}` + const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes) + if ( + existing && + (existing.authorizationAppId !== authorizationAppId || existing.scopeVersion !== scopeVersion) + ) + throw new OrchestrationError( + 'conflict', + 'Remove the previous Slack connection configuration before switching Slack apps' + ) + const option = { + id: existing?.id ?? generateId(), + provider: 'slack', + label: 'Slack', + authorizationAppId, + requiredScopes, + scopeVersion, + required: false, + status: 'active' as const, + } + await tx + .update(credentialGroup) + .set({ + options: existing + ? group.options.map((entry) => (entry.id === existing.id ? option : entry)) + : [...group.options, option], + encryptedProviderConfiguration: await encryptCredentialGroupProviderConfiguration({ + ...configuration, + slack: { + source: 'slack_app', + appId: input.appId, + teamId: input.teamId, + scopes: requiredScopes, + verifiedAt: new Date().toISOString(), + }, + }), + updatedAt: new Date(), + }) + .where(eq(credentialGroup.id, group.id)) +} diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 0e2e0ae96cb..5b2863c9d7a 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -11,7 +11,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' @@ -29,6 +29,7 @@ import { } from '@/lib/credential-groups/slack-managed-user-scopes' import type { DbOrTx } from '@/lib/db/types' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' +import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' const logger = createLogger('SlackManagedUsers') const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000 @@ -504,8 +505,10 @@ export async function createSlackManagedUsersAttempt(params: { .where( and( eq(slackApp.id, params.appId), - eq(slackApp.organizationId, scope.organizationId), - eq(slackApp.kind, 'custom') + or( + and(eq(slackApp.organizationId, scope.organizationId), eq(slackApp.kind, 'custom')), + and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) + ) ) ) .limit(1) @@ -514,6 +517,7 @@ export async function createSlackManagedUsersAttempt(params: { 'Set up this organization’s Slack app first.', 'invalid_response' ) + await requireSlackSearchAppAvailable(configured.app.id) identity = { appId: configured.app.id, teamId: configured.teamId } clientId = configured.app.clientId clientSecret = (await decryptSecret(configured.app.encryptedClientSecret)).decrypted @@ -690,12 +694,18 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { .where( and( eq(slackApp.id, params.attempt.expectedAppId), - eq(slackApp.organizationId, params.attempt.organizationId), - eq(slackApp.kind, 'custom') + or( + and( + eq(slackApp.organizationId, params.attempt.organizationId), + eq(slackApp.kind, 'custom') + ), + and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) + ) ) ) .limit(1) .for('update') + if (app?.kind === 'shared') await requireSlackSearchAppAvailable(app.id) if ( !app || !params.attempt.appRevision || diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index f603f6def12..ac06e6f834b 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { OAuthIdentityVerificationError } from '@/lib/oauth/identity-error' const { mockGetToken, mockVerifyIdentity } = vi.hoisted(() => ({ mockGetToken: vi.fn(), @@ -237,6 +238,31 @@ describe('standard OAuth Credential Group provider', () => { ).rejects.toMatchObject({ statusCode: 403 }) }) + it.each([ + new OAuthIdentityVerificationError('email_mismatch', 'emails'), + new OAuthIdentityVerificationError('email_access_denied', 'emails', 403), + new OAuthIdentityVerificationError('provider_unavailable', 'profile', 503), + ])('preserves safe identity diagnostics through managed authorization: %s', async (failure) => { + mockVerifyIdentity.mockRejectedValueOnce(failure) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).rejects.toMatchObject({ + name: 'CredentialGroupOAuthError', + statusCode: 502, + identityFailure: failure, + }) + }) + it('uses the existing Atlassian callback and state-bound identity verification', async () => { const requiredScopes = ['read:me', 'read:jira-work', 'offline_access'] const context: CredentialGroupOAuthContext = { diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 4a303538ec0..4492ad3812d 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -26,6 +26,7 @@ import { import type { CredentialGroupStandardOAuthProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { refreshOAuthToken } from '@/lib/oauth' +import { OAuthIdentityVerificationError } from '@/lib/oauth/identity-error' const OAUTH_DISCOVERY_TIMEOUT_MS = 10_000 const OAUTH_DISCOVERY_MAX_BYTES = 256 * 1024 @@ -332,10 +333,11 @@ export function createStandardOAuthCredentialGroupProviderAdapter( clientId: current.connector.clientId, expectedEmail: context.email, }) - } catch { + } catch (error) { throw new CredentialGroupOAuthError( `${service.name} returned an invalid identity token.`, - 502 + 502, + error instanceof OAuthIdentityVerificationError ? error : undefined ) } const nonceMatches = diff --git a/apps/sim/lib/credential-groups/viewer-accounts.ts b/apps/sim/lib/credential-groups/viewer-accounts.ts new file mode 100644 index 00000000000..00c99548a2d --- /dev/null +++ b/apps/sim/lib/credential-groups/viewer-accounts.ts @@ -0,0 +1,57 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limits' + +/** Own grant metadata remains manageable even while provider setup or enrollment is disabled. */ +export async function listViewerOrganizationAccounts(input: { + organizationId: string + userId: string + matching: SQL +}) { + const scope = { kind: 'organization', organizationId: input.organizationId } as const + const accounts = await db + .select({ + credentialId: credential.id, + displayName: credential.displayName, + status: credential.managedOauthStatus, + groupId: credentialGroup.id, + optionId: credential.credentialGroupOptionId, + providerId: credential.providerId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + resourceScopeCondition(credential, scope), + resourceScopeCondition(credentialGroup, scope), + eq(credentialGroupEnrollment.userId, input.userId), + eq(credential.type, 'managed_oauth'), + inArray(credential.managedOauthStatus, ['active', 'needs_reauth']), + isNull(credential.revokedAt), + input.matching + ) + ) + .limit(ORGANIZATION_VIEWER_ACCOUNT_LIMIT + 1) + if (accounts.length > ORGANIZATION_VIEWER_ACCOUNT_LIMIT) + throw new Error('Too many personal accounts for this organization') + return accounts.map((account) => { + if ( + !account.optionId || + !account.providerId || + (account.status !== 'active' && account.status !== 'needs_reauth') + ) + throw new Error('Invalid personal account metadata') + return { + ...account, + optionId: account.optionId, + providerId: account.providerId, + status: account.status, + } + }) +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts index bfaa0b47f5c..861497837ee 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts @@ -33,6 +33,7 @@ vi.mock('@sim/logger', () => { logger: createLogger(), runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), } }) diff --git a/apps/sim/lib/data-drains/destinations/bigquery.test.ts b/apps/sim/lib/data-drains/destinations/bigquery.test.ts index 31f4d98092a..c558ca09789 100644 --- a/apps/sim/lib/data-drains/destinations/bigquery.test.ts +++ b/apps/sim/lib/data-drains/destinations/bigquery.test.ts @@ -32,6 +32,7 @@ vi.mock('@sim/logger', () => ({ logger: loggerInstance, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn(async () => {}), diff --git a/apps/sim/lib/internal/slack/oauth.test.ts b/apps/sim/lib/internal/slack/oauth.test.ts index 34f787222d4..9e4deeb48cf 100644 --- a/apps/sim/lib/internal/slack/oauth.test.ts +++ b/apps/sim/lib/internal/slack/oauth.test.ts @@ -1,6 +1,10 @@ /** @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { exchangeSlackBotAuthorization } from '@/lib/internal/slack/oauth' +import { + exchangeSlackBotAuthorization, + revokeSlackBotAuthorization, + validateSlackBotAuthorization, +} from '@/lib/internal/slack/oauth' import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' const fetchMock = vi.fn() @@ -11,9 +15,9 @@ const input = { redirectUri: 'https://sim.test/api/knowledge/slack/oauth/callback', } const grant = { - ok: true, + ok: true as const, app_id: 'A1', - token_type: 'bot', + token_type: 'bot' as const, access_token: 'test-bot-token', bot_user_id: 'UBOT', scope: SLACK_SEARCH_SCOPES.join(','), @@ -34,19 +38,48 @@ describe('Slack bot OAuth exchange', () => { expect(request.body.get('redirect_uri')).toBe(input.redirectUri) expect(request.body.get('code')).toBe('code') }) + it.each([{ token_type: 'user' }, { ok: false, error: 'invalid_client_id' }])( + 'rejects incompatible or unsuccessful grants: %j', + async (change) => { + fetchMock.mockResolvedValueOnce(Response.json({ ...grant, ...change })) + await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow() + } + ) + it('does not expose provider credentials in error messages', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ ok: false, error: 'SECRET-DO-NOT-LOG' })) + await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow('Slack authorization failed') + }) +}) + +describe('Slack bot grant policy and cleanup', () => { it.each([ - { token_type: 'user' }, { is_enterprise_install: true }, { refresh_token: 'refresh' }, { expires_in: 3600 }, { scope: 'chat:write' }, - { ok: false, error: 'invalid_client_id' }, - ])('rejects incompatible or unsuccessful grants: %j', async (change) => { - fetchMock.mockResolvedValueOnce(Response.json({ ...grant, ...change })) - await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow() + ])('rejects unsupported grants after the caller takes ownership: %j', (change) => { + expect(() => validateSlackBotAuthorization({ ...grant, ...change })).toThrow() }) - it('does not expose provider credentials in error messages', async () => { + it('accepts the existing indexing bot scope policy', () => { + expect(() => validateSlackBotAuthorization(grant)).not.toThrow() + }) + it('requires the additional command scope for shared installs', () => { + expect(() => + validateSlackBotAuthorization(grant, [...SLACK_SEARCH_SCOPES, 'commands']) + ).toThrow('commands') + }) + it('revokes an unused token through Slack with a bounded request', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ ok: true, revoked: true })) + await revokeSlackBotAuthorization('unused-token') + const [url, request] = fetchMock.mock.calls[0] + expect(String(url)).toContain('/api/auth.revoke') + expect(request.headers.Authorization).toBe('Bearer unused-token') + expect(request.signal).toBeDefined() + }) + it('fails visibly when Slack does not confirm revocation', async () => { fetchMock.mockResolvedValueOnce(Response.json({ ok: false, error: 'SECRET-DO-NOT-LOG' })) - await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow('Slack authorization failed') + await expect(revokeSlackBotAuthorization('unused-token')).rejects.toThrow( + 'Slack could not revoke' + ) }) }) diff --git a/apps/sim/lib/internal/slack/oauth.ts b/apps/sim/lib/internal/slack/oauth.ts index 257fe9a3f70..ced0697443a 100644 --- a/apps/sim/lib/internal/slack/oauth.ts +++ b/apps/sim/lib/internal/slack/oauth.ts @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer' import { z } from 'zod' import { OrchestrationError } from '@/lib/core/orchestration/types' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { requestSlackApi } from '@/lib/internal/slack/client' import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' const botGrantSchema = z.object({ @@ -44,19 +45,35 @@ export async function exchangeSlackBotAuthorization(input: { 'Slack authorization failed. Check the client credentials and install the app again.' ) } - const grant = parsed.data - if (grant.is_enterprise_install || grant.refresh_token || grant.expires_in) { + return parsed.data +} + +/** Runs after exchange so the application can clean up an issued grant if policy rejects it. */ +export function validateSlackBotAuthorization( + grant: z.infer, + requiredScopes: readonly string[] = SLACK_SEARCH_SCOPES +) { + if (grant.is_enterprise_install || grant.refresh_token || grant.expires_in) throw new OrchestrationError( 'validation', 'Install the app in one workspace with token rotation disabled.' ) - } const scopes = grant.scope.split(',').map((scope) => scope.trim()) - const missing = SLACK_SEARCH_SCOPES.filter((scope) => !scopes.includes(scope)) + const missing = requiredScopes.filter((scope) => !scopes.includes(scope)) if (missing.length) throw new OrchestrationError( 'validation', `Reinstall the app with these scopes: ${missing.join(', ')}` ) - return grant +} + +/** Revokes an unused bot grant after failed setup without logging provider credentials. */ +export async function revokeSlackBotAuthorization(accessToken: string) { + const response = await requestSlackApi({ + accessToken, + method: 'auth.revoke', + signal: AbortSignal.timeout(10_000), + }) + if (response.status !== 200 || response.data.ok !== true || response.data.revoked !== true) + throw new OrchestrationError('validation', 'Slack could not revoke the unused setup token') } diff --git a/apps/sim/lib/internal/slack/search-client.ts b/apps/sim/lib/internal/slack/search-client.ts index 0b03ced53a2..ea432315f04 100644 --- a/apps/sim/lib/internal/slack/search-client.ts +++ b/apps/sim/lib/internal/slack/search-client.ts @@ -29,7 +29,11 @@ function requireSuccess(result: SlackApiResult) { } /** Verifies a workspace-installed bot and its granted scopes, never a user token. */ -export async function verifySlackSearchBot(accessToken: string, signal?: AbortSignal) { +export async function verifySlackSearchBot( + accessToken: string, + signal?: AbortSignal, + requiredScopes: readonly string[] = SLACK_SEARCH_SCOPES +) { const result = await requestSlackApi({ accessToken, method: 'auth.test', signal }) const auth = requireSuccess(result) const teamId = slackString(auth, 'team_id') @@ -40,7 +44,7 @@ export async function verifySlackSearchBot(accessToken: string, signal?: AbortSi 'Slack Search requires a bot installed in a single Slack workspace' ) } - const missing = SLACK_SEARCH_SCOPES.filter((scope) => !result.grantedScopes?.includes(scope)) + const missing = requiredScopes.filter((scope) => !result.grantedScopes?.includes(scope)) if (missing.length) throw new SlackSearchConfigurationError( `Reinstall the Slack bot with these scopes: ${missing.join(', ')}` diff --git a/apps/sim/lib/knowledge/__integration__/connector-permissions.integration.ts b/apps/sim/lib/knowledge/__integration__/connector-permissions.integration.ts new file mode 100644 index 00000000000..4809c361621 --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/connector-permissions.integration.ts @@ -0,0 +1,106 @@ +/** Real PostgreSQL coverage for provider-independent snapshots and administrator-managed grants. */ +import { db } from '@sim/db' +import { knowledgeConnector, organization, user, workspace } from '@sim/db/schema' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { seedKnowledgeAclFixture } from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { loadConnectorPermissionGroupTokens } from '@/lib/knowledge/access/connector-permissions' +import { + loadConnectorPermissionSnapshot, + readConnectorPermissionMetadata, + writeConnectorPermissions, +} from '@/lib/knowledge/connectors/permission-store' +import { connectorPermissionGroupToken } from '@/lib/knowledge/connectors/permission-tokens' + +describe('connector-owned permission storage', () => { + const owners: Awaited>[] = [] + beforeAll(async () => { + owners.push(await seedKnowledgeAclFixture()) + owners.push(await seedKnowledgeAclFixture(undefined, { connectorType: 'google_drive' })) + }) + afterAll(async () => { + for (const owner of owners) { + await db.delete(workspace).where(eq(workspace.id, owner.workspaceId)) + await db.delete(organization).where(eq(organization.id, owner.organizationId)) + await db.delete(user).where(inArray(user.id, [owner.aliceId, owner.bobId])) + } + await db.$client.end() + }) + const save = (connectorId: string, input: Parameters[2]) => + db.transaction(async (tx) => { + await tx + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connectorId)) + .for('update') + await writeConnectorPermissions(tx, connectorId, input) + }) + + it('isolates grants by connector and owner without knowing a provider payload format', async () => { + const subject = `u:${owners[0].aliceId}@fixture.test` + for (const owner of owners) { + await save(owner.connectorId, { + expectedRevision: 0, + metadata: { format: 'fixture' }, + payload: { privateRows: ['fixture-private-payload'] }, + groups: [{ groupKey: 'readers', subjects: [subject] }], + }) + expect( + await loadConnectorPermissionGroupTokens(subject, { + kind: 'workspace', + workspaceId: owner.workspaceId, + }) + ).toEqual([connectorPermissionGroupToken(owner.connectorId, 'readers')]) + } + expect(await readConnectorPermissionMetadata([owners[0].connectorId])).toEqual([ + { connectorId: owners[0].connectorId, revision: 1, metadata: { format: 'fixture' } }, + ]) + expect((await loadConnectorPermissionSnapshot(owners[0].connectorId))?.payload).toEqual({ + privateRows: ['fixture-private-payload'], + }) + await db + .update(knowledgeConnector) + .set({ accessRewritePending: true }) + .where(eq(knowledgeConnector.id, owners[0].connectorId)) + expect( + await loadConnectorPermissionGroupTokens(subject, { + kind: 'workspace', + workspaceId: owners[0].workspaceId, + }) + ).toEqual([]) + await db + .update(knowledgeConnector) + .set({ accessRewritePending: false }) + .where(eq(knowledgeConnector.id, owners[0].connectorId)) + }) + + it('rolls back private writes and rejects stale revisions before a replacement can restore grants', async () => { + const owner = owners[0] + const before = await loadConnectorPermissionSnapshot(owner.connectorId) + const update = { + expectedRevision: 1, + metadata: { format: 'updated' }, + payload: { privateRows: ['replacement-private-payload'] }, + groups: [{ groupKey: 'readers', subjects: ['invalid-private-subject'] }], + } + await expect(save(owner.connectorId, update)).rejects.toMatchObject({ + code: 'internal', + message: 'Could not save permissions. The previous configuration was preserved.', + }) + expect(await loadConnectorPermissionSnapshot(owner.connectorId)).toEqual(before) + const replacements = await Promise.allSettled([ + save(owner.connectorId, { ...update, groups: [] }), + save(owner.connectorId, { ...update, groups: [] }), + ]) + expect(replacements.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(replacements.find((result) => result.status === 'rejected')).toMatchObject({ + reason: { code: 'conflict' }, + }) + expect( + await loadConnectorPermissionGroupTokens(`u:${owner.aliceId}@fixture.test`, { + kind: 'workspace', + workspaceId: owner.workspaceId, + }) + ).toEqual([]) + }) +}) diff --git a/apps/sim/lib/knowledge/__integration__/github-member.integration.ts b/apps/sim/lib/knowledge/__integration__/github-member.integration.ts index 31cd2ddc32e..133ebdce20d 100644 --- a/apps/sim/lib/knowledge/__integration__/github-member.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/github-member.integration.ts @@ -52,6 +52,7 @@ import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' import { encryptSecret } from '@/lib/core/security/encryption' import { resetStorageMethod } from '@/lib/core/storage' import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { completeCredentialGroupEnrollment, getCredentialGroupOAuthContext, @@ -77,13 +78,17 @@ import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' import { subjectToken } from '@/lib/knowledge/access/tokens' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks' +import { + createKnowledgeConnector, + listKnowledgeConnectorDocuments, +} from '@/lib/knowledge/application/connectors' import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document' import { searchKnowledge } from '@/lib/knowledge/application/search' import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview' import { listSearchSources } from '@/lib/knowledge/application/search-sources' import { grantKnowledgeConnectorCredentialAccess } from '@/lib/knowledge/connectors/member-access' -import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine' +import * as memberSyncEngine from '@/lib/knowledge/connectors/member-sync-engine' import { MEMBER_SUSPENDED_PURGE_DAYS, MEMBER_TOMBSTONE_PURGE_DAYS, @@ -120,6 +125,7 @@ interface RepositoryFixture { deniedStatus: 403 | 404 throttledReaders: Set throttledBlobReaders: Set + failedBlobs: Set truncated: boolean } @@ -186,6 +192,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { deniedStatus: 404, throttledReaders: new Set(), throttledBlobReaders: new Set(), + failedBlobs: new Set(), truncated: false, } repositories.set(name, value) @@ -250,18 +257,22 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { if (url.pathname === '/app/installations/42/access_tokens') { expect(request.method).toBe('POST') const body = await request.json() - expect(body).toMatchObject({ - permissions: { contents: 'read', metadata: 'read' }, - }) - expect(body.repository_ids).toHaveLength(1) - const repositoryId = body.repository_ids[0] + const contentToken = body.permissions.contents === 'read' + expect(body.permissions).toEqual( + contentToken ? { contents: 'read', metadata: 'read' } : { metadata: 'read' } + ) + if (contentToken) expect(body.repository_ids).toHaveLength(1) + else expect(body.repositories).toHaveLength(1) + const repositoryId = contentToken + ? body.repository_ids[0] + : repositories.get(body.repositories[0])?.id expect( [...repositories.values()].some( (repository) => repository.id === repositoryId && repository.installed ) ).toBe(true) return Response.json({ - token: `ghs_fixture_installation_${repositoryId}`, + token: `ghs_fixture_${contentToken ? 'installation' : 'metadata'}_${repositoryId}`, expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), permissions: body.permissions, repositories: [{ id: repositoryId }], @@ -275,7 +286,10 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { return Response.json(installation()) } if (request.method !== 'GET') throw new Error(`Unexpected GitHub method: ${request.method}`) - const installationRepository = bearer.match(/^ghs_fixture_installation_(\d+)$/)?.[1] + const installationRepository = bearer.match( + /^ghs_fixture_(?:installation|metadata)_(\d+)$/ + )?.[1] + const metadataToken = bearer.startsWith('ghs_fixture_metadata_') const installationToken = Boolean(installationRepository) const member = enrolled.members.find((candidate) => [tokenFor(candidate.userId), `${tokenFor(candidate.userId)}_refreshed`].some( @@ -330,6 +344,13 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { private: !source.public, default_branch: source.defaultBranch, }) + expect(metadataToken).toBe(false) + if (match[2].startsWith('/branches/')) { + const branch = decodeURIComponent(match[2].slice('/branches/'.length)) + return branch === source.defaultBranch + ? Response.json({ name: branch, commit: { sha: shaFor(branch) }, protected: false }) + : Response.json({ message: 'Not Found' }, { status: 404 }) + } if (match[2].startsWith('/git/ref/heads/')) { referenceObserved?.(match[1]) if (source.stallRef) @@ -377,6 +398,12 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { { status: 403 } ) const sha = decodeURIComponent(match[2].slice('/git/blobs/'.length)) + if (source.failedBlobs.has(sha)) { + return Response.json( + { message: 'Fixture provider unavailable' }, + { status: 503, headers: { 'Retry-After': '3600' } } + ) + } const content = [...source.files.values(), ...source.symlinks.values()].find( (value) => shaFor(value) === sha ) @@ -547,6 +574,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) await db.delete(organization).where(eq(organization.id, ids.organizationId)) await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + vi.restoreAllMocks() vi.unstubAllGlobals() } }) @@ -593,7 +621,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { .update(knowledgeConnectorMember) .set({ nextAttemptAt: new Date(0) }) .where(eq(knowledgeConnectorMember.connectorId, connectorId)) - return executeMemberSync(connectorId, { + return memberSyncEngine.executeMemberSync(connectorId, { billingAttribution: billing, forceContentRefresh, }) @@ -644,7 +672,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { } } - it('indexes an organization installation once and denies live user, app, and org revocations before search or reads', async () => { + async function useOrganizationInstallation() { organizationSource = true Object.assign(env, { GITHUB_APP_ID: '1', @@ -663,6 +691,19 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { .update(credentialGroup) .set({ workspaceId: null, organizationId: ids.organizationId }) .where(eq(credentialGroup.id, enrolled.groupId)) + await db + .update(resourcePolicy) + .set({ + workspaceId: null, + organizationId: ids.organizationId, + document: buildOrganizationAccountAccessPolicy(enrolled.groupId, []), + }) + .where( + and( + eq(resourcePolicy.resourceType, 'credential_group'), + eq(resourcePolicy.resourceId, enrolled.groupId) + ) + ) await db .update(credential) .set({ workspaceId: null, organizationId: ids.organizationId }) @@ -719,6 +760,231 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { actorUserId: ids.aliceId, organizationId: ids.organizationId, }) + return installationCredentialId + } + + it('reuses connected organization members for a later installation source without another enrollment', async () => { + const installationCredentialId = await useOrganizationInstallation() + expect((await sync()).error).toBeUndefined() + const [shared] = await rows() + const enrollmentsBefore = await db + .select({ + id: credentialGroupEnrollment.id, + userId: credentialGroupEnrollment.userId, + status: credentialGroupEnrollment.status, + }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, enrolled.groupId)) + .orderBy(credentialGroupEnrollment.id) + const credentialsBefore = await db + .select({ + id: credential.id, + enrollmentId: credential.credentialGroupEnrollmentId, + subjectId: credential.providerSubjectId, + }) + .from(credential) + .where(eq(credential.credentialGroupOptionId, enrolled.optionId)) + .orderBy(credential.id) + const later = repository('later', [ids.aliceId]) + const input = { + knowledgeBaseId: ids.knowledgeBaseId, + assertedOrganizationId: ids.organizationId, + connectorType: 'github', + accessMode: 'members' as const, + credentialId: installationCredentialId, + sourceConfig: { repository: 'fixture/later' }, + syncIntervalMinutes: 0, + } + await expect( + createKnowledgeConnector.execute({ principal: actor(ids.bobId), input }) + ).rejects.toThrow() + const dispatchedSync = vi.spyOn(memberSyncEngine, 'executeMemberSync') + const { connector } = await createKnowledgeConnector.execute({ + principal: actor(ids.aliceId), + input, + }) + try { + expect(dispatchedSync).toHaveBeenCalledExactlyOnceWith(connector.id, expect.any(Object)) + expect((await dispatchedSync.mock.results[0].value).error).toBeUndefined() + } finally { + dispatchedSync.mockRestore() + } + const connectorId = connector.id + expect(connector).toMatchObject({ + credentialGroupId: enrolled.groupId, + credentialGroupOptionId: enrolled.optionId, + sourceConfig: { repository: 'fixture/later', githubRepositoryId: String(later.id) }, + }) + const [current] = await db + .select() + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connectorId)) + expect(current).toMatchObject({ memberSyncStatus: 'idle' }) + expect(current.lastMemberSyncAt).not.toBeNull() + const memberships = await db + .select() + .from(knowledgeConnectorMember) + .where(eq(knowledgeConnectorMember.connectorId, connectorId)) + expect(memberships).toHaveLength(2) + expect(memberships.map((row) => row.credentialId).sort()).toEqual( + credentialsBefore.map((row) => row.id).sort() + ) + expect( + await db + .select({ + id: credentialGroupEnrollment.id, + userId: credentialGroupEnrollment.userId, + status: credentialGroupEnrollment.status, + }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, enrolled.groupId)) + .orderBy(credentialGroupEnrollment.id) + ).toEqual(enrollmentsBefore) + expect( + await db + .select({ + id: credential.id, + enrollmentId: credential.credentialGroupEnrollmentId, + subjectId: credential.providerSubjectId, + }) + .from(credential) + .where(eq(credential.credentialGroupOptionId, enrolled.optionId)) + .orderBy(credential.id) + ).toEqual(credentialsBefore) + const [indexed] = await rows(connectorId) + expect(await search(actor(ids.aliceId))).toEqual([shared.id, indexed.id].sort()) + expect(await search(actor(ids.bobId))).toEqual([shared.id]) + await assertAccess(actor(ids.aliceId), indexed, true) + await assertAccess(actor(ids.bobId), indexed, false) + const indexedRead = (userId: string) => + readIndexedKnowledgeDocument.execute({ + principal: actor(userId), + input: { + organizationId: ids.organizationId, + target: { kind: 'id', documentId: indexed.id }, + limit: 10, + resultSecretRegistry: new ResolvedSecretTraceRegistry(), + }, + }) + expect( + (await indexedRead(ids.aliceId)).chunks?.map((chunk) => chunk.content).join('\n') + ).toContain('Orion later') + await expect(indexedRead(ids.bobId)).rejects.toThrow('Document not found') + /** Organization cache bytes are internal; members read through the authorized Search operation. */ + await expect( + downloadFileFromUrl(indexed.fileUrl, { userId: ids.aliceId, knowledgeAccess: 'user' }) + ).rejects.toThrow('Access denied') + await expect( + downloadFileFromUrl(indexed.fileUrl, { userId: ids.bobId, knowledgeAccess: 'user' }) + ).rejects.toThrow('Access denied') + for (const userId of [ids.aliceId, ids.bobId]) { + const { sources } = await listSearchSources.execute({ + principal: actor(userId), + input: { organizationId: ids.organizationId, connectorId }, + }) + expect(sources).toMatchObject([ + { + connectorId, + viewerMembership: 'connected', + viewerDocumentCount: userId === ids.aliceId ? 1 : 0, + }, + ]) + } + expect( + requests + .filter((entry) => entry.path.startsWith('/repos/fixture/later/git/blobs/')) + .map((entry) => entry.userId) + ).toEqual(['installation']) + }) + + it('keeps actual skips, legacy skips, and provider failures distinct in authorized lists and source counts', async () => { + await useOrganizationInstallation() + const source = repositories.get('shared')! + source.readers.delete(ids.bobId) + source.files.set('empty.txt', '') + source.files.set('image.png', 'binary\0contents') + expect((await sync()).error).toBeUndefined() + const initial = await rows() + const empty = initial.find((row) => row.externalId === 'empty.txt')! + const binary = initial.find((row) => row.externalId === 'image.png')! + expect(empty).toMatchObject({ processingStatus: 'failed', storageKey: null }) + expect(binary).toMatchObject({ processingStatus: 'failed', storageKey: null }) + expect(empty.contentHash).not.toBeNull() + await db.update(document).set({ processingStatus: 'failed' }).where(eq(document.id, empty.id)) + const summary = async (userId: string) => + ( + await listSearchSources.execute({ + principal: actor(userId), + input: { organizationId: ids.organizationId, connectorId: enrolled.connectorId }, + }) + ).sources[0] + expect(await summary(ids.aliceId)).toMatchObject({ + viewerDocumentCount: 1, + viewerFailedDocumentCount: 0, + hasSyncError: false, + }) + source.files.set('unavailable.txt', 'Orion content whose blob cannot be fetched.') + source.failedBlobs.add(shaFor(source.files.get('unavailable.txt')!)) + await sync() + await db.update(document).set({ processingStatus: 'failed' }).where(eq(document.id, empty.id)) + const failed = (await rows()).find((row) => row.externalId === 'unavailable.txt')! + expect(failed).toMatchObject({ + processingStatus: 'failed', + storageKey: null, + contentHash: null, + }) + for (const userId of [ids.aliceId, ids.bobId]) { + const provider = createKnowledgeAccessProvider(actor(userId), { + organizationId: ids.organizationId, + knowledgeBaseIds: [ids.knowledgeBaseId], + }) + const listed = await getDocuments(ids.knowledgeBaseId, {}, 'github-skip-outcomes', provider) + expect(listed.pagination.total).toBe(userId === ids.aliceId ? 4 : 0) + if (userId === ids.aliceId) { + expect(listed.documents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: empty.id, + processingStatus: 'failed', + processingOutcome: 'skipped', + }), + expect.objectContaining({ + id: binary.id, + processingStatus: 'failed', + processingOutcome: 'skipped', + }), + expect.objectContaining({ id: failed.id, processingStatus: 'failed' }), + ]) + ) + } + for (const filter of ['failed', 'skipped'] as const) { + const outcomes = await listKnowledgeConnectorDocuments.execute({ + principal: actor(userId), + input: { + connectorId: enrolled.connectorId, + knowledgeBaseId: ids.knowledgeBaseId, + filter, + }, + }) + expect(outcomes.counts).toMatchObject({ + failed: userId === ids.aliceId ? 1 : 0, + skipped: userId === ids.aliceId ? 2 : 0, + }) + expect(outcomes.documents.map((row) => row.id).sort()).toEqual( + userId === ids.aliceId + ? (filter === 'failed' ? [failed.id] : [empty.id, binary.id]).sort() + : [] + ) + } + expect(await summary(userId)).toMatchObject({ + viewerDocumentCount: userId === ids.aliceId ? 1 : 0, + viewerFailedDocumentCount: userId === ids.aliceId ? 1 : 0, + }) + } + }) + + it('indexes an organization installation once and denies live user, app, and org revocations before search or reads', async () => { + const installationCredentialId = await useOrganizationInstallation() const unrelatedSources = Array.from({ length: 105 }, () => generateId()) await db.insert(knowledgeConnector).values( unrelatedSources.map((id) => ({ diff --git a/apps/sim/lib/knowledge/__integration__/gitlab-live.integration.ts b/apps/sim/lib/knowledge/__integration__/gitlab-live.integration.ts index e568ded8457..0ffcd293d9f 100644 --- a/apps/sim/lib/knowledge/__integration__/gitlab-live.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/gitlab-live.integration.ts @@ -1,33 +1,65 @@ /** - * Opt-in self-hosted GitLab test. GITLAB_LIVE_FIXTURE_FILE contains {url, token} - * for a disposable instance at https://localhost:8443; NODE_EXTRA_CA_CERTS must - * trust its fixture certificate. Uses real provider APIs, encrypted PAT storage, - * sync engines, parsing, Postgres, and application authorization. Only embeddings - * are substituted. Only this fixture workspace's stored files are removed afterward. + * Opt-in self-hosted GitLab test. GITLAB_LIVE_FIXTURE_FILE contains {url, token, + * auditorToken?} for a disposable localhost HTTPS instance. The administrator + * token seeds fixture data; an optional read_api Auditor token exercises the CSV + * path on a licensed instance. NODE_EXTRA_CA_CERTS must trust its certificate. + * Provider APIs, encrypted PAT storage, ingestion, Postgres and authorization are + * real. Model outputs/capacity are substituted; requestContext supplies Next's + * headers for authenticated route-handler calls. Cleanup removes only fixture + * resources and files, including the fixture organization and workspace. */ + +import { AsyncLocalStorage } from 'node:async_hooks' import { readFile } from 'node:fs/promises' import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { document, embedding, + knowledgeBase, knowledgeConnector, + knowledgeConnectorPermissionGrant, + knowledgeConnectorPermissionSnapshot, + member, + organization, + organizationSearchIntegration, permissions, + session, user, workspace, workspaceFiles, } from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' -import { and, eq, inArray, isNull } from 'drizzle-orm' +import { serializeSignedCookie } from 'better-call' +import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { NextRequest } from 'next/server' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { EmbedOptions } from '@/lib/embeddings/types' const fixture = vi.hoisted(() => ({ embeddingCalls: 0 })) -vi.mock('@/lib/embeddings', async () => ({ - ...(await import('@/lib/embeddings/client')), +const requestContext = new AsyncLocalStorage() +vi.mock('next/headers', () => ({ + headers: async () => requestContext.getStore() ?? new Headers(), + cookies: async () => ({ get: () => undefined, set: () => {} }), +})) +vi.mock('@/lib/embeddings/client', () => ({ + BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE: 'Fixture embedding credential rejected', + EMBEDDING_QUOTA_EXHAUSTED_MESSAGE: 'Fixture embedding quota exhausted', + EmbeddingOutputLimitError: class extends Error {}, + getEmbeddingAggregateItemLimit: () => 1000, + isBYOKEmbeddingCredentialRejection: () => false, + isEmbeddingQuotaExhaustion: () => false, + embed: () => { + throw new Error('Unexpected model call in GitLab fixture') + }, + embedOpenRouter: () => { + throw new Error('Unexpected model call in GitLab fixture') + }, assertKnowledgeEmbeddingCapacity: async () => {}, - embedKnowledge: async (texts: string[]) => { - fixture.embeddingCalls += 1 + embedKnowledge: async (texts: string[], options?: EmbedOptions) => { + if (options?.taskType !== 'query') fixture.embeddingCalls += 1 return { embeddings: texts.map(() => [1, ...Array(1535).fill(0)]), totalTokens: texts.length, @@ -39,18 +71,30 @@ vi.mock('@/lib/embeddings', async () => ({ }, })) -import { encryptApiKey } from '@/lib/api-key/crypto' -import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import type { + ConnectorData, + CreateConnectorBody, + UpdateConnectorBody, +} from '@/lib/api/contracts/knowledge/connectors' +import { decryptApiKey, encryptApiKey } from '@/lib/api-key/crypto' +import { + resolveBillingAttribution, + resolveOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' import { seedKnowledgeAclFixture } from '@/lib/knowledge/__integration__/seed-source-access-fixture' import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks' import { updateKnowledgeConnectorAccess } from '@/lib/knowledge/application/connector-access' import { updateKnowledgeConnector } from '@/lib/knowledge/application/connectors' import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' +import { readSearchDocument } from '@/lib/knowledge/application/read-search-document' import { searchKnowledge } from '@/lib/knowledge/application/search' import { executeSync } from '@/lib/knowledge/connectors/sync-engine' import * as storage from '@/lib/uploads/core/storage-service' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' +import { PATCH as updateConnectorRoute } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route' +import { POST as createConnectorRoute } from '@/app/api/knowledge/[id]/connectors/route' import { gitlabConnector } from '@/connectors/gitlab/gitlab' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' interface GitLabPerson { id: number @@ -70,6 +114,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission let ids: Awaited> let base: string let adminToken: string + let auditorToken: string | undefined let groupId: number | undefined let projectId: number let issueIid: number @@ -80,6 +125,8 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission const createdUserIds: number[] = [] const extraSimIds: string[] = [] let config: Record + let adminCookie: string + let readerCookie: string const principal = (person: GitLabPerson): Principal => ({ kind: 'personal_api_key', userId: person.simId, @@ -91,6 +138,17 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission keyId: 'gitlab-live-fixture', }) + async function waitForProjectAccess(person: GitLabPerson, expectedStatus: number) { + let status = 0 + const deadline = Date.now() + 300_000 + while (Date.now() < deadline) { + status = (await response(`/projects/${projectId}`, person.token)).status + if (status === expectedStatus) return + await sleep(1000) + } + expect(status, 'GitLab project authorization did not converge').toBe(expectedStatus) + } + async function response( resource: string, token = adminToken, @@ -197,13 +255,35 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission const access: unknown = JSON.parse(await readFile(fixtureFile!, 'utf8')) if ( !isPlainRecord(access) || - access.url !== 'https://localhost:8443' || + typeof access.url !== 'string' || + !/^https:\/\/localhost:\d+$/.test(access.url) || typeof access.token !== 'string' ) throw new Error('Only the explicit disposable localhost GitLab fixture is supported') base = access.url adminToken = access.token + auditorToken = typeof access.auditorToken === 'string' ? access.auditorToken : undefined ids = await seedKnowledgeAclFixture() + async function sessionCookie(userId: string) { + const token = generateId() + await db.insert(session).values({ + id: generateId(), + token, + userId, + expiresAt: new Date(Date.now() + 3_600_000), + createdAt: new Date(), + updatedAt: new Date(), + }) + return ( + await serializeSignedCookie( + 'better-auth.session_token', + token, + process.env.BETTER_AUTH_SECRET! + ) + ).split(';')[0] + } + adminCookie = await sessionCookie(ids.aliceId) + readerCookie = await sessionCookie(ids.bobId) const suffix = generateId().replaceAll('-', '') const group = await api('/groups', 'POST', { name: `Sim Search E2E ${suffix}`, @@ -220,7 +300,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission default_branch: 'main', }) projectId = project.id - config = { host: 'localhost:8443', project: project.path_with_namespace, contentTypes: 'all' } + config = { host: new URL(base).host, project: project.path_with_namespace, contentTypes: 'all' } for (const [name, role] of [ ['reporter', 20], ['guest', 10], @@ -261,7 +341,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission 'POST', { name: 'Disposable search test', - scopes: ['api'], + scopes: ['read_api'], expires_at: new Date(Date.now() + 86400000).toISOString().slice(0, 10), } ) @@ -330,7 +410,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission encryptedApiKey: (await encryptApiKey(adminToken)).encrypted, }) .where(eq(knowledgeConnector.id, ids.connectorId)) - }, 180000) + }, 600000) afterAll(async () => { const cleanup = await Promise.allSettled([ @@ -343,7 +423,12 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission const files = await db .select({ key: workspaceFiles.key }) .from(workspaceFiles) - .where(eq(workspaceFiles.workspaceId, ids.workspaceId)) + .where( + or( + eq(workspaceFiles.workspaceId, ids.workspaceId), + eq(workspaceFiles.organizationId, ids.organizationId) + ) + ) for (const file of files) { try { await storage.deleteFile({ key: file.key, context: 'knowledge-base' }) @@ -353,6 +438,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission } } await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId, ...extraSimIds])) } })(), @@ -362,6 +448,9 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission }, 120000) it('validates a custom HTTPS instance and ingests repository, wiki, issues and merge requests through the real engine', async () => { + for (const person of [people.reporter, people.guest, people.planner]) { + await waitForProjectAccess(person, 200) + } expect(await gitlabConnector.validateConfig!(adminToken, config)).toEqual({ valid: true }) expect( await gitlabConnector.validateConfig!(adminToken, config, { mirrorsSourceAcls: true }) @@ -397,7 +486,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission expect(content).toContain('Orion merge review comment.') expect(content).not.toContain('INTERNAL_FIXTURE_NOTE_MUST_NOT_BE_INDEXED') expect(await search(workspaceKey())).toEqual(new Set()) - }, 120000) + }, 600000) it('matches GitLab private-project decisions for inherited Reporter, Guest, Planner, outside and external users', async () => { await assertProviderParity(['reporter', 'guest', 'planner', 'outsider', 'external']) @@ -423,6 +512,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission expect(await storedVectors()).toEqual(before) await assertProviderParity(['reporter', 'guest']) await api(`/groups/${groupId}/members/${people.guest.id}`, 'DELETE') + await waitForProjectAccess(people.guest, 404) await sync() await assertProviderParity(['guest']) await api(`/groups/${groupId}/members`, 'POST', { user_id: people.guest.id, access_level: 10 }) @@ -430,7 +520,8 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission assignee_ids: [people.guest.id], }) await api(`/groups/${groupId}/members/${people.reporter.id}`, 'PUT', { access_level: 20 }) - }, 120000) + await waitForProjectAccess(people.guest, 200) + }, 600000) it('indexes edited comments and updates confidential permissions on an unchanged issue body', async () => { await api(`/projects/${projectId}/issues/${issueIid}/notes/${publicNoteId}`, 'PUT', { @@ -468,7 +559,7 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission await api(`/users/${people.reporter.id}/unblock`, 'POST') }, 180000) - it('switches between explicit workspace sharing and mirrored access with immediate authorization changes', async () => { + it('rejects new workspace sharing and upgrades legacy GitLab connections with immediate authorization changes', async () => { const scope = { knowledgeBaseId: ids.knowledgeBaseId, connectorId: ids.connectorId, @@ -490,20 +581,21 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission input: { ...scope, updates: { status: 'paused' } }, }) const before = await storedVectors() - const shared = await updateKnowledgeConnectorAccess.execute({ - principal: actor, - input: { ...scope, accessMode: 'workspace' }, - }) - expect(shared.changed).toBe(true) + await expect( + updateKnowledgeConnectorAccess.execute({ + principal: actor, + input: { ...scope, accessMode: 'workspace' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await db + .update(knowledgeConnector) + .set({ accessMode: 'workspace' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + await db + .update(document) + .set({ acl: ['ws'], aclRequirements: [] }) + .where(eq(document.connectorId, ids.connectorId)) expect(await search(workspaceKey())).toEqual(new Set((await stored()).map((row) => row.id))) - expect( - ( - await updateKnowledgeConnectorAccess.execute({ - principal: actor, - input: { ...scope, accessMode: 'workspace' }, - }) - ).changed - ).toBe(false) await updateKnowledgeConnectorAccess.execute({ principal: actor, input: { ...scope, accessMode: 'admin' }, @@ -558,4 +650,379 @@ describe.skipIf(!fixtureFile)('live self-hosted GitLab ingestion and permission expect(await stored()).toEqual([]) expect(await search(principal(people.reporter))).toEqual(new Set()) }, 120000) + + async function connectorRequest( + knowledgeBaseId: string, + body: CreateConnectorBody | UpdateConnectorBody, + connectorId?: string, + cookie = adminCookie + ) { + const headers = new Headers({ + cookie, + 'content-type': 'application/json', + origin: 'http://localhost:3000', + }) + const path = `/api/knowledge/${knowledgeBaseId}/connectors${connectorId ? `/${connectorId}` : ''}` + const request = new NextRequest(`http://localhost:3000${path}`, { + method: connectorId ? 'PATCH' : 'POST', + headers, + body: JSON.stringify(body), + }) + return requestContext.run(headers, async () => + connectorId + ? updateConnectorRoute(request, { + params: Promise.resolve({ id: knowledgeBaseId, connectorId }), + }) + : createConnectorRoute(request, { params: Promise.resolve({ id: knowledgeBaseId }) }) + ) + } + + async function waitForSync(connectorId: string) { + for (let attempt = 0; attempt < 600; attempt++) { + const [row] = await db + .select() + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connectorId)) + if (row && !['syncing', 'pending'].includes(row.status)) { + expect(row.lastSyncError).toBeNull() + return row + } + await sleep(100) + } + throw new Error('Disposable connector sync did not finish within one minute') + } + + it.each([false, true])( + 'enforces non-admin CSV permissions through authenticated API, indexing and every read surface (Search=%s)', + async (isSearchIndex) => { + await api(`/projects/${projectId}`, 'PUT', { visibility: 'private' }) + await api(`/projects/${projectId}/issues/${issueIid}`, 'PUT', { confidential: false }) + const owner = isSearchIndex + ? { organizationId: ids.organizationId } + : { workspaceId: ids.workspaceId } + if (isSearchIndex) { + await db.insert(member).values( + Object.values(people).map((person) => ({ + id: generateId(), + userId: person.simId, + organizationId: ids.organizationId, + role: person.simId === ids.aliceId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await db + .insert(organizationSearchIntegration) + .values({ organizationId: ids.organizationId, connectorType: 'gitlab', approved: true }) + } + const attribution = isSearchIndex + ? await resolveOrganizationBillingAttribution({ + actorUserId: ids.aliceId, + organizationId: ids.organizationId, + }) + : await resolveBillingAttribution({ + actorUserId: ids.aliceId, + workspaceId: ids.workspaceId, + }) + const knowledgeBaseId = generateId() + await db.insert(knowledgeBase).values({ + id: knowledgeBaseId, + userId: ids.aliceId, + ...owner, + name: `CSV live fixture ${isSearchIndex ? 'Search' : 'KB'}`, + isSearchIndex, + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 20 }, + }) + const mapping = { + filename: 'users.csv', + content: `user_id,email\n${people.reporter.id},${people.reporter.email}\n${people.guest.id},${people.guest.email}\n`, + } + const projectPermissions = (userId: number) => ({ + filename: 'permissions.csv', + content: `project_path,user_id\n${config.project},${userId}\nunrelated/project,${people.guest.id}\n${config.project},999999999\n`, + }) + if (!auditorToken) await waitForProjectAccess(people.reporter, 200) + const indexingToken = auditorToken ?? people.reporter.token + const createBody: CreateConnectorBody = { + connectorType: 'gitlab', + accessMode: 'admin', + apiKey: indexingToken, + sourceConfig: { ...config, contentTypes: 'issues' }, + syncIntervalMinutes: 60, + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + userMapping: mapping, + projectPermissions: projectPermissions(people.reporter.id), + }, + } + const denied = await connectorRequest(knowledgeBaseId, createBody, undefined, readerCookie) + expect(denied.status).toBe(403) + const unsupported = await connectorRequest(knowledgeBaseId, { + ...createBody, + accessMode: 'workspace', + }) + expect(unsupported.status).toBe(400) + const inaccessibleProject = await connectorRequest(knowledgeBaseId, { + ...createBody, + sourceConfig: { ...createBody.sourceConfig, project: 'missing-fixture-project' }, + }) + expect(inaccessibleProject.status).toBe(400) + const createdResponse = await connectorRequest(knowledgeBaseId, createBody) + const created = await createdResponse.json() + expect(createdResponse.status, JSON.stringify(created)).toBe(201) + let connector = created.data as ConnectorData + expect(connector.permissionConfig).toMatchObject({ + provider: 'gitlab', + mode: 'csv', + revision: 1, + userMapping: { rowCount: 2 }, + }) + expect(JSON.stringify(created)).not.toContain(indexingToken) + expect(JSON.stringify(created)).not.toContain(people.reporter.email) + expect(connector.sourceConfig).not.toHaveProperty('permissionConfig') + const storedConnector = await waitForSync(connector.id) + expect(storedConnector.encryptedApiKey).not.toBe(indexingToken) + expect( + (await decryptApiKey(storedConnector.encryptedApiKey!)).decrypted === indexingToken + ).toBe(true) + const docs = await db.select().from(document).where(eq(document.connectorId, connector.id)) + const ordinary = docs.find((doc) => doc.externalId === `issue:${issueIid}`)! + expect(ordinary.processingStatus).toBe('completed') + const excluded = docs.find((doc) => doc.externalId === `issue:${confidentialIid}`) + if (excluded) { + expect(excluded.acl).toEqual([]) + expect(excluded.storageKey).toBeNull() + } + const searchFor = async (person: GitLabPerson) => + ( + await searchKnowledge.execute({ + principal: principal(person), + input: { + ...owner, + knowledgeBaseIds: [knowledgeBaseId], + query: 'Orion', + topK: 100, + }, + }) + ).results.map((row) => row.documentId) + const assertReads = async (person: GitLabPerson, allowed: boolean) => { + expect((await searchFor(person)).includes(ordinary.id)).toBe(allowed) + const operations: Array<() => Promise> = [ + () => + readKnowledgeDocument.execute({ + principal: principal(person), + input: { knowledgeBaseId, documentId: ordinary.id }, + }), + () => + listKnowledgeChunks.execute({ + principal: principal(person), + input: { knowledgeBaseId, documentId: ordinary.id, limit: 10, offset: 0 }, + }), + ] + const download = () => + downloadFileFromUrl(ordinary.fileUrl!, { userId: person.simId, knowledgeAccess: 'user' }) + if (isSearchIndex) { + await expect(download()).rejects.toThrow('Access denied') + operations.push(() => + readSearchDocument.execute({ + principal: principal(person), + input: { + documentId: ordinary.id, + assertedOrganizationId: ids.organizationId, + offset: 0, + limit: 10, + resultSecretRegistry: new ResolvedSecretTraceRegistry(), + }, + }) + ) + } else operations.push(download) + for (const operation of operations) { + if (allowed) await expect(operation()).resolves.toBeDefined() + else await expect(operation()).rejects.toBeDefined() + } + } + await assertReads(people.reporter, true) + await assertReads(people.guest, false) + await assertReads(people.outsider, false) + const vectors = await db + .select({ content: embedding.content }) + .from(embedding) + .where(eq(embedding.documentId, ordinary.id)) + expect(vectors.map((row) => row.content).join('\n')).not.toContain( + 'INTERNAL_FIXTURE_NOTE_MUST_NOT_BE_INDEXED' + ) + const before = fixture.embeddingCalls + const tooLarge = await connectorRequest( + knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 1, + userMapping: { filename: 'large.csv', content: 'x'.repeat(4 * 1024 * 1024 + 1) }, + }, + }, + connector.id + ) + expect(tooLarge.status).toBe(400) + const overRequestLimit = await connectorRequest( + knowledgeBaseId, + { + sourceConfig: { description: 'x'.repeat(10 * 1024 * 1024) }, + }, + connector.id + ) + expect(overRequestLimit.status).toBe(413) + const invalid = await connectorRequest( + knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 1, + userMapping: { filename: 'bad.csv', content: '1,not-an-email' }, + }, + }, + connector.id + ) + expect(invalid.status).toBe(400) + await assertReads(people.reporter, true) + const replace = await connectorRequest( + knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 1, + projectPermissions: projectPermissions(people.guest.id), + }, + }, + connector.id + ) + expect(replace.status).toBe(200) + connector = (await replace.json()).data + expect(connector.permissionConfig?.revision).toBe(2) + await assertReads(people.reporter, false) + await assertReads(people.guest, true) + expect(fixture.embeddingCalls).toBe(before) + const stale = await connectorRequest( + knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 1, + projectPermissions: projectPermissions(people.reporter.id), + }, + }, + connector.id + ) + expect(stale.status).toBe(409) + const concurrent = await Promise.all( + [people.reporter, people.guest].map((person) => + connectorRequest( + knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 2, + projectPermissions: projectPermissions(person.id), + }, + }, + connector.id + ) + ) + ) + expect(concurrent.map((result) => result.status).sort()).toEqual([200, 409]) + const grants = await db + .select() + .from(knowledgeConnectorPermissionGrant) + .where(eq(knowledgeConnectorPermissionGrant.connectorId, connector.id)) + expect(grants).toHaveLength(1) + let winner = + grants[0].subjectToken === `u:${people.reporter.email}` ? people.reporter : people.guest + await assertReads(winner, true) + expect(fixture.embeddingCalls).toBe(before) + await db.update(user).set({ emailVerified: false }).where(eq(user.id, winner.simId)) + await assertReads(winner, false) + await db.update(user).set({ emailVerified: true }).where(eq(user.id, winner.simId)) + const removedMember = winner + const newlyMappedMember = winner === people.reporter ? people.guest : people.reporter + const remapped = await connectorRequest( + knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 3, + userMapping: { + filename: 'replacement-users.csv', + content: `${winner.id},${newlyMappedMember.email}`, + }, + }, + }, + connector.id + ) + expect(remapped.status).toBe(200) + await assertReads(removedMember, false) + winner = newlyMappedMember + await assertReads(winner, true) + expect(fixture.embeddingCalls).toBe(before) + const crossOwner = await connectorRequest( + ids.knowledgeBaseId, + { + permissionConfig: { + provider: 'gitlab', + mode: 'csv', + expectedRevision: 3, + projectPermissions: projectPermissions(people.reporter.id), + }, + }, + connector.id + ) + expect(crossOwner.status).toBe(404) + await api(`/projects/${projectId}/issues/${issueIid}`, 'PUT', { confidential: true }) + const syncResult = await executeSync(connector.id, { + billingAttribution: attribution, + fullSync: true, + }) + expect(syncResult.docsFailed).toBe(0) + await assertReads(winner, false) + const [removed] = await db.select().from(document).where(eq(document.id, ordinary.id)) + expect(removed.storageKey).toBeNull() + expect(removed.acl).toEqual([]) + expect( + await db.select().from(embedding).where(eq(embedding.documentId, ordinary.id)) + ).toEqual([]) + const [snapshot] = await db + .select() + .from(knowledgeConnectorPermissionSnapshot) + .where(eq(knowledgeConnectorPermissionSnapshot.connectorId, connector.id)) + expect(snapshot.revision).toBe(4) + await connectorRequest(knowledgeBaseId, { status: 'paused' }, connector.id) + const switched = await connectorRequest( + knowledgeBaseId, + { + apiKey: adminToken, + permissionConfig: { provider: 'gitlab', mode: 'administrator', expectedRevision: 4 }, + }, + connector.id + ) + expect(switched.status).toBe(200) + expect((await switched.json()).data.accessRewritePending).toBe(true) + await assertReads(winner, false) + await connectorRequest(knowledgeBaseId, { status: 'active' }, connector.id) + await executeSync(connector.id, { + billingAttribution: attribution, + fullSync: true, + }) + const [restored] = await db + .select() + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connector.id)) + expect(restored.accessRewritePending).toBe(false) + }, + 180000 + ) }) diff --git a/apps/sim/lib/knowledge/__integration__/organization-search-overview.integration.ts b/apps/sim/lib/knowledge/__integration__/organization-search-overview.integration.ts index b93f9ca5709..1ec01b7aff5 100644 --- a/apps/sim/lib/knowledge/__integration__/organization-search-overview.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/organization-search-overview.integration.ts @@ -153,7 +153,14 @@ beforeEach(async () => { .where(eq(knowledgeConnectorMember.id, memberId)) await db .update(document) - .set({ processingStatus: 'completed', enabled: true, userExcluded: false }) + .set({ + processingStatus: 'completed', + enabled: true, + userExcluded: false, + contentHash: null, + storageKey: null, + fileUrl: 'https://fixture.test/private', + }) .where(eq(document.id, documentId)) }) @@ -276,9 +283,44 @@ describe('organization operational overview with real SQL', () => { }) expect(await provider('gmail')).toMatchObject({ status: 'active' }) }) - it('reports inaccessible processing failures without exposing documents, and ignores excluded failures', async () => { - await db.update(document).set({ processingStatus: 'failed' }).where(eq(document.id, documentId)) - expect(await provider('google_drive')).toMatchObject({ status: 'needs_attention' }) + it('ignores intentional skips while reporting inaccessible source and indexing failures', async () => { + await db + .update(document) + .set({ processingStatus: 'failed', contentHash: 'immutable-sha', fileUrl: '' }) + .where(eq(document.id, documentId)) + expect(await provider('google_drive')).toMatchObject({ + status: 'active', + issue: null, + isSyncing: false, + }) + await db + .update(knowledgeConnector) + .set({ lastSyncError: 'previous sync failed' }) + .where(eq(knowledgeConnector.id, driveId)) + expect(await provider('google_drive')).toMatchObject({ + status: 'needs_attention', + issue: 'sync_failed', + isSyncing: false, + }) + await db + .update(knowledgeConnector) + .set({ lastSyncError: null }) + .where(eq(knowledgeConnector.id, driveId)) + await db.update(document).set({ contentHash: null }).where(eq(document.id, documentId)) + expect(await provider('google_drive')).toMatchObject({ + status: 'needs_attention', + issue: 'document_indexing_failed', + isSyncing: false, + }) + await db + .update(document) + .set({ contentHash: 'immutable-sha', storageKey: 'fixture-retained-artifact' }) + .where(eq(document.id, documentId)) + expect(await provider('google_drive')).toMatchObject({ + status: 'needs_attention', + issue: 'document_indexing_failed', + isSyncing: false, + }) await db.update(document).set({ userExcluded: true }).where(eq(document.id, documentId)) expect(await provider('google_drive')).toMatchObject({ status: 'active' }) await db diff --git a/apps/sim/lib/knowledge/__integration__/search-source-progress.integration.ts b/apps/sim/lib/knowledge/__integration__/search-source-progress.integration.ts index 5a82344815a..8e1cc0d70b6 100644 --- a/apps/sim/lib/knowledge/__integration__/search-source-progress.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-source-progress.integration.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { document, + embedding, knowledgeBase, knowledgeConnector, organization, @@ -9,16 +10,29 @@ import { workspace, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import type { ConnectorDocumentFilter } from '@/lib/api/contracts/knowledge/connectors' +import * as embeddings from '@/lib/embeddings' import { createKnowledgeAclFixtureIds, seedKnowledgeAclFixture, } from '@/lib/knowledge/__integration__/seed-source-access-fixture' -import { listKnowledgeConnectorDocuments } from '@/lib/knowledge/application/connectors' +import { + deleteKnowledgeConnector, + listKnowledgeConnectorDocuments, +} from '@/lib/knowledge/application/connectors' +import { + listKnowledgeDocuments, + readKnowledgeDocument, + updateKnowledgeDocument, +} from '@/lib/knowledge/application/documents' import { readSearchSourceProgress } from '@/lib/knowledge/application/search-source-progress' import { listSearchSources } from '@/lib/knowledge/application/search-sources' +import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock' +import { persistSkippedDocuments } from '@/lib/knowledge/connectors/sync-persistence' +import * as documentProcessor from '@/lib/knowledge/documents/document-processor' +import { processDocumentAsync, retryDocumentProcessing } from '@/lib/knowledge/documents/service' const ids = createKnowledgeAclFixtureIds() const alice = { kind: 'session' as const, userId: ids.aliceId, sessionId: 'fixture-alice' } @@ -207,14 +221,15 @@ describe('connector document filename search and document sets', () => { active: [failureId, ...planIds], excluded: [excludedId], failed: [failureId], + skipped: [], } - for (const filter of ['active', 'excluded', 'failed'] as const) { + for (const filter of ['active', 'excluded', 'failed', 'skipped'] as const) { const result = await listKnowledgeConnectorDocuments.execute({ principal: viewer, input: { ...scope, filter, search: ' nEeDle ' }, }) expect(result.documents.map((row) => row.id)).toEqual(expectedIds[filter]) - expect(result.counts).toEqual({ active: 3, excluded: 1, failed: 1 }) + expect(result.counts).toEqual({ active: 3, excluded: 1, failed: 1, skipped: 0 }) expect(result.hasMore).toBe(false) } }) @@ -244,7 +259,7 @@ describe('connector document filename search and document sets', () => { input: { ...scope, filter: 'active', search }, }) expect(result.documents.map((row) => row.id)).toEqual([id]) - expect(result.counts).toEqual({ active: 1, excluded: 0, failed: 0 }) + expect(result.counts).toEqual({ active: 1, excluded: 0, failed: 0, skipped: 0 }) } }) @@ -269,7 +284,7 @@ describe('connector document filename search and document sets', () => { input: { ...scope, filter: 'active', search: 'needle' }, }) expect(privateResult.documents.map((row) => row.id)).toEqual([privateId]) - expect(privateResult.counts).toEqual({ active: 1, excluded: 0, failed: 1 }) + expect(privateResult.counts).toEqual({ active: 1, excluded: 0, failed: 1, skipped: 0 }) await db .delete(permissions) @@ -284,3 +299,362 @@ describe('connector document filename search and document sets', () => { ).rejects.toThrow('Insufficient workspace permissions') }) }) + +describe('intentional skips and genuine failures across document reads', () => { + const fixture = createKnowledgeAclFixtureIds() + const viewer = { + kind: 'session' as const, + userId: fixture.aliceId, + sessionId: 'fixture-outcomes', + } + const otherViewer = { ...viewer, userId: fixture.bobId } + const legacySkipId = generateId() + const sourceFailureId = generateId() + const indexingFailureId = generateId() + const privateLegacySkipId = generateId() + const scope = { knowledgeBaseId: fixture.knowledgeBaseId, connectorId: fixture.connectorId } + const expectedCounts = { active: 4, excluded: 0, failed: 2, skipped: 2 } + const skipIds = [legacySkipId] + const failureIds = [sourceFailureId, indexingFailureId] + const privateIds = [privateLegacySkipId] + + beforeAll(async () => { + await seedKnowledgeAclFixture(fixture, { connectorType: 'google_drive' }) + await db + .update(knowledgeBase) + .set({ isSearchIndex: true }) + .where(eq(knowledgeBase.id, fixture.knowledgeBaseId)) + const rows: Array & { id: string; filename: string }> = [ + { + id: legacySkipId, + filename: 'outcome-a-legacy.png', + fileUrl: '', + uploadedAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: sourceFailureId, + filename: 'outcome-c-source.txt', + contentHash: null, + fileUrl: '', + processingError: 'Source download unavailable', + }, + { + id: indexingFailureId, + filename: 'outcome-d-indexing.txt', + storageKey: 'fixture-retained-artifact', + processingError: 'Embedding provider unavailable', + }, + { + id: privateLegacySkipId, + filename: 'outcome-e-private-legacy.png', + fileUrl: '', + acl: [`u:${fixture.bobId}@fixture.test`], + }, + ] + await db.insert(document).values( + rows.map((row) => ({ + knowledgeBaseId: fixture.knowledgeBaseId, + connectorId: fixture.connectorId, + externalId: row.id, + fileUrl: `https://fixture.test/${row.id}`, + fileSize: 10, + mimeType: 'text/plain', + processingStatus: 'failed', + contentHash: `git-sha:${row.id}`, + storageKey: null, + processingError: 'A source omission reason whose wording may change', + acl: [`u:${fixture.aliceId}@fixture.test`], + aclVerifiedAt: new Date(), + ...row, + })) + ) + for (const [externalId, title, userId, documentIds] of [ + ['current-skip', 'outcome-b-skipped.png', fixture.aliceId, skipIds], + ['private-current-skip', 'outcome-f-private-skipped.png', fixture.bobId, privateIds], + ] as const) { + const [persisted] = await persistSkippedDocuments( + fixture.knowledgeBaseId, + fixture.connectorId, + 'google_drive', + [ + { + type: 'skip', + extDoc: { + externalId, + title, + content: '', + mimeType: 'text/plain', + contentHash: `git-sha:${externalId}`, + skippedReason: 'Current worker intentionally omitted this file', + }, + }, + ], + undefined, + 'admin', + createContentSyncLease(fixture.connectorId, fixture.lockId) + ) + documentIds.push(persisted.documentId) + await db + .update(document) + .set({ acl: [`u:${userId}@fixture.test`], aclVerifiedAt: new Date() }) + .where(eq(document.id, persisted.documentId)) + } + await db + .update(knowledgeConnector) + .set({ status: 'active', syncLockToken: null }) + .where(eq(knowledgeConnector.id, fixture.connectorId)) + }) + + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, fixture.workspaceId)) + await db.delete(organization).where(eq(organization.id, fixture.organizationId)) + await db.delete(user).where(eq(user.id, fixture.aliceId)) + await db.delete(user).where(eq(user.id, fixture.bobId)) + }) + + it('projects legacy and current skips before authorized filtering, counting, and pagination', async () => { + const expectedIds: Record = { + active: [...skipIds, ...failureIds], + excluded: [], + failed: failureIds, + skipped: skipIds, + } + for (const filter of ['active', 'excluded', 'failed', 'skipped'] as const) { + const result = await listKnowledgeConnectorDocuments.execute({ + principal: viewer, + input: { ...scope, filter, search: 'outcome-' }, + }) + expect(result.documents.map((row) => row.id)).toEqual(expectedIds[filter]) + expect(result.counts).toEqual(expectedCounts) + expect(result.hasMore).toBe(false) + for (const row of result.documents) { + expect(row.processingStatus).toBe('failed') + expect(row.processingOutcome).toBe(skipIds.includes(row.id) ? 'skipped' : null) + expect(row.processingError).toBeTruthy() + } + } + const pages = await Promise.all( + [0, 1].map((offset) => + listKnowledgeConnectorDocuments.execute({ + principal: viewer, + input: { ...scope, filter: 'skipped', limit: 1, offset }, + }) + ) + ) + expect(pages.flatMap((page) => page.documents.map((row) => row.id))).toEqual(skipIds) + expect(pages.map((page) => page.hasMore)).toEqual([true, false]) + expect(pages.map((page) => page.counts)).toEqual([expectedCounts, expectedCounts]) + const legacyFailures = await listKnowledgeConnectorDocuments.execute({ + principal: viewer, + input: { ...scope, failedOnly: true }, + }) + expect(legacyFailures.documents.map((row) => row.id)).toEqual(failureIds) + }) + + it('does not turn another viewer’s skips into indexing errors or expose their documents', async () => { + for (const [principal, failedCount] of [ + [viewer, 2], + [otherViewer, 0], + ] as const) { + const sources = await listSearchSources.execute({ + principal, + input: { workspaceId: fixture.workspaceId }, + }) + expect(sources.sources[0].viewerFailedDocumentCount).toBe(failedCount) + const progress = await readSearchSourceProgress.execute({ + principal, + input: { workspaceId: fixture.workspaceId, connectorIds: [fixture.connectorId] }, + }) + expect(progress.sources).toEqual([ + { + connectorId: fixture.connectorId, + isSyncing: false, + hasSyncError: false, + hasIndexingError: failedCount > 0, + }, + ]) + } + for (const filter of ['active', 'skipped', 'failed'] as const) { + const result = await listKnowledgeConnectorDocuments.execute({ + principal: otherViewer, + input: { ...scope, filter }, + }) + expect(result.documents.map((row) => row.id)).toEqual(filter === 'failed' ? [] : privateIds) + expect(result.counts).toEqual({ active: 2, excluded: 0, failed: 0, skipped: 2 }) + } + await expect( + readKnowledgeDocument.execute({ + principal: viewer, + input: { knowledgeBaseId: fixture.knowledgeBaseId, documentId: privateLegacySkipId }, + }) + ).rejects.toThrow('Document not found') + await expect( + readKnowledgeDocument.execute({ + principal: otherViewer, + input: { knowledgeBaseId: fixture.knowledgeBaseId, documentId: sourceFailureId }, + }) + ).rejects.toThrow('Document not found') + }) + + it('uses the same status for the regular knowledge base list and document detail', async () => { + const result = await listKnowledgeDocuments.execute({ + principal: viewer, + input: { knowledgeBaseId: fixture.knowledgeBaseId }, + }) + expect(result.documents.map((row) => row.id).sort()).toEqual([...skipIds, ...failureIds].sort()) + for (const row of result.documents) { + const expectedOutcome = skipIds.includes(row.id) ? 'skipped' : null + expect(row.processingStatus).toBe('failed') + expect(row.processingOutcome).toBe(expectedOutcome) + const detail = await readKnowledgeDocument.execute({ + principal: viewer, + input: { knowledgeBaseId: fixture.knowledgeBaseId, documentId: row.id }, + }) + expect(detail.document.processingStatus).toBe('failed') + expect(detail.document.processingOutcome).toBe(expectedOutcome) + expect(detail.document.processingError).toBe(row.processingError) + } + }) + + it('rejects skipped and source-failed retries without changing stored outcomes or embedding dispatch', async () => { + const embed = vi + .spyOn(embeddings, 'embedKnowledge') + .mockRejectedValue(new Error('Unexpected embedding dispatch')) + const retryIds = [...skipIds, sourceFailureId] + const before = await db.select().from(document).where(inArray(document.id, retryIds)) + try { + for (const documentId of retryIds) { + await expect( + updateKnowledgeDocument.execute({ + principal: viewer, + input: { knowledgeBaseId: fixture.knowledgeBaseId, documentId, retryProcessing: true }, + }) + ).rejects.toThrow( + documentId === sourceFailureId ? 'Sync the connector' : 'intentionally skipped' + ) + } + for (const documentId of skipIds) { + const stored = before.find((row) => row.id === documentId)! + const result = await retryDocumentProcessing( + fixture.knowledgeBaseId, + documentId, + stored, + 'fixture-skipped-retry', + undefined + ) + expect(result).toMatchObject({ success: false, status: 'skipped' }) + expect(result.message).toContain('intentionally skipped') + } + const after = await db.select().from(document).where(inArray(document.id, retryIds)) + expect(after.sort((left, right) => left.id.localeCompare(right.id))).toEqual( + before.sort((left, right) => left.id.localeCompare(right.id)) + ) + expect( + await db.select().from(embedding).where(inArray(embedding.documentId, retryIds)) + ).toEqual([]) + expect(embed).not.toHaveBeenCalled() + } finally { + embed.mockRestore() + } + }) + + it.each([false, true])( + 'preserves legacy and current skips against delayed tokenless workers (missing context: %s)', + async (missingContext) => { + const processor = vi + .spyOn(documentProcessor, 'processDocument') + .mockRejectedValue(new Error('Unexpected skipped document processing')) + const embed = vi + .spyOn(embeddings, 'embedKnowledge') + .mockRejectedValue(new Error('Unexpected embedding dispatch')) + const before = await db.select().from(document).where(inArray(document.id, skipIds)) + try { + if (missingContext) { + await db + .update(knowledgeBase) + .set({ deletedAt: new Date() }) + .where(eq(knowledgeBase.id, fixture.knowledgeBaseId)) + } + for (const row of before) { + await processDocumentAsync( + fixture.knowledgeBaseId, + row.id, + row, + {}, + undefined, + 'fixture-delayed-tokenless-worker', + { chargedAtDispatch: false } + ) + } + const after = await db.select().from(document).where(inArray(document.id, skipIds)) + expect(after.sort((left, right) => left.id.localeCompare(right.id))).toEqual( + before.sort((left, right) => left.id.localeCompare(right.id)) + ) + expect(processor).not.toHaveBeenCalled() + expect(embed).not.toHaveBeenCalled() + } finally { + if (missingContext) { + await db + .update(knowledgeBase) + .set({ deletedAt: null }) + .where(eq(knowledgeBase.id, fixture.knowledgeBaseId)) + } + processor.mockRestore() + embed.mockRestore() + } + } + ) + + it('preserves skip outcomes and real failures when a workspace source is removed with documents kept', async () => { + await expect( + deleteKnowledgeConnector.execute({ + principal: viewer, + input: { ...scope, deleteDocuments: false }, + }) + ).rejects.toThrow('cannot be kept') + await db + .update(knowledgeBase) + .set({ isSearchIndex: false }) + .where(eq(knowledgeBase.id, fixture.knowledgeBaseId)) + await db + .update(knowledgeConnector) + .set({ accessMode: 'workspace' }) + .where(eq(knowledgeConnector.id, fixture.connectorId)) + /** Workspace syncs write workspace ACLs; detached rows must not retain mirrored grants. */ + await db + .update(document) + .set({ acl: ['ws'], aclRequirements: [] }) + .where(eq(document.connectorId, fixture.connectorId)) + const result = await deleteKnowledgeConnector.execute({ + principal: viewer, + input: { ...scope, deleteDocuments: false }, + }) + expect(result).toMatchObject({ documentsDeleted: 0, documentsKept: 6 }) + const retained = await db + .select() + .from(document) + .where(eq(document.knowledgeBaseId, fixture.knowledgeBaseId)) + expect(retained).toHaveLength(6) + for (const row of retained) { + expect(row.connectorId).toBeNull() + expect(row.acl).toEqual(['ws']) + expect(row.processingStatus).toBe('failed') + } + for (const documentId of skipIds) { + for (const principal of [viewer, otherViewer]) { + const detail = await readKnowledgeDocument.execute({ + principal, + input: { knowledgeBaseId: fixture.knowledgeBaseId, documentId }, + }) + expect(detail.document.processingStatus).toBe('failed') + expect(detail.document.processingOutcome).toBe('skipped') + } + await expect( + updateKnowledgeDocument.execute({ + principal: viewer, + input: { knowledgeBaseId: fixture.knowledgeBaseId, documentId, retryProcessing: true }, + }) + ).rejects.toThrow('intentionally skipped') + } + }) +}) diff --git a/apps/sim/lib/knowledge/__integration__/slack-empty-threads.integration.ts b/apps/sim/lib/knowledge/__integration__/slack-empty-threads.integration.ts new file mode 100644 index 00000000000..2ec84a824a7 --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/slack-empty-threads.integration.ts @@ -0,0 +1,233 @@ +/** Real sync jobs, PostgreSQL, storage, indexing and authorized search; Slack and embedding responses are synthetic. */ +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { db } from '@sim/db' +import { + document, + embedding, + knowledgeConnector, + organization, + user, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ storageRoot: '', embeddingCalls: 0 })) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + get UPLOAD_DIR_SERVER() { + return fixture.storageRoot + }, +})) +vi.mock('@/lib/embeddings', async () => ({ + ...(await import('@/lib/embeddings/client')), + assertKnowledgeEmbeddingCapacity: async () => {}, + embedKnowledge: async (texts: string[]) => { + fixture.embeddingCalls++ + return { + embeddings: texts.map(() => [1, ...Array(1535).fill(0)]), + totalTokens: texts.length, + billableTokens: 0, + isBYOK: true, + modelName: 'text-embedding-3-small', + pricingId: 'text-embedding-3-small', + } + }, +})) + +import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { searchKnowledge } from '@/lib/knowledge/application/search' +import * as connectorTokens from '@/lib/knowledge/connectors/access-token' +import { executeConnectorSyncJob } from '@/background/knowledge-connector-sync' + +const TEAM = 'T0FIXTURE' +const CHANNEL = 'C0GENERAL' +const ROOT = '1700000100.000100' +const REPLY = '1700000200.000100' +const EXTERNAL_ID = `slack:v4:${TEAM}:${CHANNEL}:${ROOT}` +const EMPTY_REASON = 'Document contains no extractable text' + +describe('Slack empty threads through sync jobs, indexing and search', () => { + const ids = createKnowledgeAclFixtureIds() + const documentId = generateId() + let billing: Awaited> + let replyText = '' + let incomplete = false + let missingRoot = false + const channel = { id: CHANNEL, name: 'general', is_archived: false } + const root = () => ({ type: 'message', ts: ROOT, thread_ts: ROOT, text: '', reply_count: 1 }) + + async function providerFetch(input: string | URL | Request, init?: RequestInit) { + const url = new URL( + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + ) + expect(url.origin).toBe('https://slack.com') + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer fixture-slack-token') + const method = url.pathname.split('/').at(-1) + switch (method) { + case 'auth.test': + return Response.json({ ok: true, team_id: TEAM }) + case 'conversations.list': + return Response.json({ ok: true, channels: [channel] }) + case 'conversations.info': + return Response.json({ ok: true, channel }) + case 'conversations.history': + return Response.json({ ok: true, messages: [root()] }) + case 'conversations.replies': + if (missingRoot) return Response.json({ ok: true, messages: [] }) + if (!url.searchParams.has('cursor')) { + return Response.json({ + ok: true, + messages: [root()], + has_more: true, + response_metadata: { next_cursor: 'reply' }, + }) + } + return Response.json({ + ok: true, + messages: [{ type: 'message', ts: REPLY, thread_ts: ROOT, text: replyText }], + is_limited: incomplete, + }) + case 'chat.getPermalink': + return Response.json({ + ok: true, + permalink: `https://fixture.slack.com/archives/${CHANNEL}/p${ROOT.replace('.', '')}`, + }) + default: + throw new Error('Unexpected fixture Slack endpoint') + } + } + async function sync() { + return executeConnectorSyncJob({ + connectorId: ids.connectorId, + requestId: 'slack-empty-fixture', + fullSync: true, + billingAttribution: billing, + }) + } + async function row() { + const [stored] = await db + .select() + .from(document) + .where(and(eq(document.connectorId, ids.connectorId), eq(document.externalId, EXTERNAL_ID))) + expect(stored?.id).toBe(documentId) + return stored! + } + async function vectors() { + return db + .select({ content: embedding.content }) + .from(embedding) + .where(eq(embedding.documentId, documentId)) + } + async function search() { + const result = await searchKnowledge.execute({ + principal: { kind: 'session', userId: ids.aliceId, sessionId: 'slack-empty-fixture' }, + input: { + workspaceId: ids.workspaceId, + knowledgeBaseIds: [ids.knowledgeBaseId], + query: 'Orion', + searchMode: 'hybrid', + topK: 10, + }, + }) + return result.results.map((result) => result.documentId) + } + beforeAll(async () => { + fixture.storageRoot = mkdtempSync(path.join(tmpdir(), 'sim-slack-empty-')) + await seedKnowledgeAclFixture(ids) + billing = await resolveBillingAttribution({ + actorUserId: ids.aliceId, + workspaceId: ids.workspaceId, + }) + await db + .update(knowledgeConnector) + .set({ + connectorType: 'slack', + sourceConfig: { channel: CHANNEL, maxMessages: 0 }, + accessMode: 'workspace', + status: 'active', + syncLockToken: null, + }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + vi.spyOn(connectorTokens, 'resolveConnectorAccessToken').mockResolvedValue({ + accessToken: 'fixture-slack-token', + }) + vi.stubGlobal('fetch', providerFetch) + await db.insert(document).values({ + id: documentId, + knowledgeBaseId: ids.knowledgeBaseId, + connectorId: ids.connectorId, + externalId: EXTERNAL_ID, + filename: 'Thread.txt', + mimeType: 'text/plain', + fileUrl: '', + fileSize: 0, + processingStatus: 'failed', + processingError: 'Synthetic previous source failure', + }) + }) + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + await rm(fixture.storageRoot, { recursive: true, force: true }) + vi.restoreAllMocks() + vi.unstubAllGlobals() + await db.$client.end() + }) + + it('completes empty-thread syncs, recovers reply edits and removes stale searchable text', async () => { + expect(await sync()).toMatchObject({ + outcome: 'completed', + docsFailed: 0, + docsSkipped: 1, + processingDispatch: { requested: 0, failed: 0 }, + }) + expect(await row()).toMatchObject({ + processingStatus: 'failed', + processingError: EMPTY_REASON, + storageKey: null, + }) + expect(await vectors()).toEqual([]) + expect(fixture.embeddingCalls).toBe(0) + expect(await sync()).toMatchObject({ outcome: 'completed', docsFailed: 0 }) + expect(fixture.embeddingCalls).toBe(0) + + replyText = 'Orion launch is scheduled for Friday.' + expect(await sync()).toMatchObject({ outcome: 'completed', docsFailed: 0, docsUpdated: 1 }) + expect(await row()).toMatchObject({ processingStatus: 'completed', processingError: null }) + expect((await vectors()).map((row) => row.content).join(' ')).toContain(replyText) + expect(await search()).toContain(documentId) + const embedded = fixture.embeddingCalls + expect(await sync()).toMatchObject({ outcome: 'completed', docsUnchanged: 1 }) + expect(fixture.embeddingCalls).toBe(embedded) + + replyText = '' + expect(await sync()).toMatchObject({ outcome: 'completed', docsFailed: 0, docsSkipped: 1 }) + expect(await row()).toMatchObject({ processingError: EMPTY_REASON, storageKey: null }) + expect(await vectors()).toEqual([]) + expect(fixture.embeddingCalls).toBe(embedded) + expect(await search()).not.toContain(documentId) + + replyText = 'Orion launch moved to Monday.' + expect(await sync()).toMatchObject({ outcome: 'completed', docsUpdated: 1 }) + expect(await search()).toContain(documentId) + const restored = await vectors() + incomplete = true + replyText = '' + await expect(sync()).rejects.toThrow('1 source failures') + expect(await vectors()).toEqual(restored) + expect((await row()).storageKey).not.toBeNull() + incomplete = false + missingRoot = true + await expect(sync()).rejects.toThrow('1 source failures') + expect(await vectors()).toEqual(restored) + }, 60000) +}) diff --git a/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts b/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts index 9e2369fba14..740cfa9fc69 100644 --- a/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts @@ -123,7 +123,10 @@ describe('durable Slack Search turns in PostgreSQL', () => { await db.$client.end() }) - function job(threadTs = '1000.000001', messageTs = '1000.000002'): SlackSearchJob { + function job( + threadTs = '1000.000001', + messageTs = '1000.000002' + ): SlackSearchJob & { message: SlackSearchJob['message'] & { messageTs: string } } { return { installationId, credentialId, diff --git a/apps/sim/lib/knowledge/access/connector-permissions.ts b/apps/sim/lib/knowledge/access/connector-permissions.ts new file mode 100644 index 00000000000..f6ec99eee99 --- /dev/null +++ b/apps/sim/lib/knowledge/access/connector-permissions.ts @@ -0,0 +1,35 @@ +import { db } from '@sim/db' +import { + knowledgeBase, + knowledgeConnector, + knowledgeConnectorPermissionGrant, +} from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { connectorPermissionGroupToken } from '@/lib/knowledge/connectors/permission-tokens' + +/** The caller has already verified this email and current membership in the canonical owner. */ +export async function loadConnectorPermissionGroupTokens( + subjectToken: string, + scope: ResourceScope +): Promise { + const grant = knowledgeConnectorPermissionGrant + const rows = await db + .select({ connectorId: grant.connectorId, groupKey: grant.groupKey }) + .from(grant) + .innerJoin(knowledgeConnector, eq(knowledgeConnector.id, grant.connectorId)) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) + .where( + and( + eq(grant.subjectToken, subjectToken), + eq(knowledgeConnector.accessMode, 'admin'), + eq(knowledgeConnector.accessRewritePending, false), + resourceScopeCondition(knowledgeBase, scope), + isNull(knowledgeBase.deletedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeConnector.archivedAt) + ) + ) + return rows.map((row) => connectorPermissionGroupToken(row.connectorId, row.groupKey)) +} diff --git a/apps/sim/lib/knowledge/access/predicate.ts b/apps/sim/lib/knowledge/access/predicate.ts index 8de2cb47f79..c2bdf6d6c41 100644 --- a/apps/sim/lib/knowledge/access/predicate.ts +++ b/apps/sim/lib/knowledge/access/predicate.ts @@ -217,6 +217,7 @@ function storedKnowledgeAccessCondition( OR EXISTS ( SELECT 1 FROM ${knowledgeConnector} WHERE ${knowledgeConnector.id} = ${document.connectorId} + AND ${knowledgeConnector.accessRewritePending} = false AND ${searchIntegrationAccessCondition()} AND ${liveSourceAccess} AND ( diff --git a/apps/sim/lib/knowledge/access/scope.test.ts b/apps/sim/lib/knowledge/access/scope.test.ts index bb46c448f15..c91a38b0ae5 100644 --- a/apps/sim/lib/knowledge/access/scope.test.ts +++ b/apps/sim/lib/knowledge/access/scope.test.ts @@ -11,11 +11,13 @@ const { mockCheckWorkspaceAccess, mockGitHubReadGrants, mockConfluenceReadGrants, + mockCsvGrants, } = vi.hoisted(() => ({ mockAvailability: vi.fn(async () => ({ memberScoped: true, sourceMirrored: true })), mockCheckWorkspaceAccess: vi.fn(async () => ({ hasAccess: true })), mockGitHubReadGrants: vi.fn(async () => []), mockConfluenceReadGrants: vi.fn(async () => []), + mockCsvGrants: vi.fn(async () => [] as string[]), })) vi.mock('@/lib/knowledge/access/availability', () => ({ @@ -30,6 +32,9 @@ vi.mock('@/lib/knowledge/access/confluence-site', () => ({ vi.mock('@/lib/knowledge/access/github-installation', () => ({ resolveGitHubInstallationReadGrants: mockGitHubReadGrants, })) +vi.mock('@/lib/knowledge/access/connector-permissions', () => ({ + loadConnectorPermissionGroupTokens: mockCsvGrants, +})) import { createKnowledgeAccessProvider, @@ -261,6 +266,25 @@ describe('createKnowledgeAccessProvider', () => { expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) }) + it('reports live-source readers only when a member-scoped source credential exists', async () => { + queueSubjects([{ providerId: 'slack', providerTenantId: 'T1', providerSubjectId: 'U1' }]) + await expect( + createKnowledgeAccessProvider(SESSION, WORKSPACE).hasLiveSourceReaders?.() + ).resolves.toBe(false) + + queueSubjects([ + { + providerId: 'confluence', + providerTenantId: 'site-1', + providerSubjectId: 'account-1', + credentialId: 'credential-1', + }, + ]) + await expect( + createKnowledgeAccessProvider(SESSION, WORKSPACE).hasLiveSourceReaders?.() + ).resolves.toBe(true) + }) + it('retries after a failed lookup rather than caching the failure', async () => { dbChainMockFns.where.mockRejectedValueOnce(new Error('connection reset')) const provider = createKnowledgeAccessProvider(SESSION, WORKSPACE) diff --git a/apps/sim/lib/knowledge/access/scope.ts b/apps/sim/lib/knowledge/access/scope.ts index 0fa1a187299..31ce1201124 100644 --- a/apps/sim/lib/knowledge/access/scope.ts +++ b/apps/sim/lib/knowledge/access/scope.ts @@ -24,6 +24,7 @@ import { type ConfluenceReaderCredential, resolveConfluenceSiteReadGrants, } from '@/lib/knowledge/access/confluence-site' +import { loadConnectorPermissionGroupTokens } from '@/lib/knowledge/access/connector-permissions' import { domainMemberWildcard, EXTERNAL_GROUP_STALE_AFTER_MS, @@ -274,6 +275,10 @@ async function loadUserAccess( const email = rows[0]?.email const own = userToken(email) if (own) identityTokens.add(own) + if (own) { + for (const token of await loadConnectorPermissionGroupTokens(own, scope)) + identityTokens.add(token) + } const groupMemberTokens = [...identityTokens] if (own && email) groupMemberTokens.push(domainMemberWildcard(emailDomain(email))) if (groupMemberTokens.length > 0) { @@ -392,6 +397,10 @@ function createAccessProvider( async get() { return (await identity()).access }, + async hasLiveSourceReaders() { + const { access, githubReaders, confluenceReaders } = await identity() + return access.kind === 'user' && (githubReaders.length > 0 || confluenceReaders.length > 0) + }, async getForConnectors(connectorIds, signal) { const ids = boundedIds(connectorIds) const cancellation = diff --git a/apps/sim/lib/knowledge/access/types.ts b/apps/sim/lib/knowledge/access/types.ts index 88995f2d3eb..028ee9efc04 100644 --- a/apps/sim/lib/knowledge/access/types.ts +++ b/apps/sim/lib/knowledge/access/types.ts @@ -93,6 +93,12 @@ export interface KnowledgeAccessProvider { documentIds: readonly string[], signal?: AbortSignal ): Promise + /** + * Whether the reader holds any credential a live source (GitHub, Confluence) could + * authorize beyond the stored ACL. Without one, candidate discovery can only re-prove + * the ordinary predicate, so readers skip it. Absent means unknown: discover. + */ + hasLiveSourceReaders?(): Promise } /** Two existing search legs each contribute at most 200 candidates to one authorization batch. */ diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index 0e0358850c4..d80c90158a6 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -21,6 +21,11 @@ const mocks = vi.hoisted(() => ({ sourceAccess: vi.fn(), oauthContext: vi.fn(), startOAuth: vi.fn(), + authorizeOrganization: vi.fn(), + credential: vi.fn(), + decrypt: vi.fn(), + installationBinding: vi.fn(), + repository: vi.fn(), })) vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() })) @@ -32,6 +37,26 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: async () => null, })) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + authorizeOrganizationOperation: mocks.authorizeOrganization, +})) +vi.mock('@/lib/knowledge/application/connector-credential', () => ({ + requireConnectorCredential: mocks.credential, +})) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decrypt })) +vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error { + constructor( + message: string, + readonly status?: number, + readonly operation?: string + ) { + super(message) + } + }, + parseGitHubInstallationBinding: mocks.installationBinding, + resolveGitHubInstallationRepository: mocks.repository, +})) vi.mock('@/lib/knowledge/application/contexts', () => ({ resolveActiveKnowledgeConnectorContext: mocks.context, })) @@ -80,10 +105,12 @@ vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ provisionKnowledgeConnectorMembersBinding: mocks.provision, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { startKnowledgeConnectorMemberEnrollment, updateKnowledgeConnectorAccess, } from '@/lib/knowledge/application/connector-access' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' const principal = { kind: 'session' as const, userId: 'admin', sessionId: 'session' } const input = { knowledgeBaseId: 'kb', connectorId: 'source', assertedWorkspaceId: 'workspace' } @@ -131,6 +158,155 @@ beforeEach(() => { mocks.sourceAccess.mockResolvedValue(undefined) mocks.oauthContext.mockResolvedValue({ credentialOwnerId: 'admin', option: { id: 'option' } }) mocks.startOAuth.mockResolvedValue('https://provider.example.test/authorize') + mocks.authorizeOrganization.mockResolvedValue({ + organizationId: 'org', + userId: 'admin', + role: 'admin', + }) +}) + +describe('GitHub installation connection replacement', () => { + const sourceConfig = { repository: 'acme/platform', githubRepositoryId: '123', branch: 'main' } + const replacementInput = { + knowledgeBaseId: 'kb', + connectorId: 'source', + accessMode: 'members' as const, + credentialId: 'replacement-installation', + } + + beforeEach(() => { + mocks.context.mockResolvedValue({ + organizationId: 'org', + knowledgeBaseId: 'kb', + connectorId: 'source', + knowledgeBase: { organizationId: 'org', id: 'kb', name: 'Search', isSearchIndex: true }, + }) + mocks.connector.mockResolvedValue({ + ...row, + connectorType: 'github', + accessMode: 'members', + credentialId: 'previous-installation', + sourceConfig, + }) + mocks.meta.mockReturnValue({ + name: 'GitHub', + search: true, + auth: { mode: 'oauth', provider: 'github-repositories' }, + supportsSeparateContentCredential: true, + }) + mocks.credential.mockResolvedValue({ + id: replacementInput.credentialId, + providerId: 'github-app-installation', + organizationId: 'org', + workspaceId: null, + type: 'service_account', + revokedAt: null, + encryptedServiceAccountKey: 'encrypted-binding', + providerSubjectId: '42', + providerTenantId: '7', + }) + mocks.decrypt.mockResolvedValue({ decrypted: '{}' }) + mocks.installationBinding.mockReturnValue({ installationId: '42', accountId: '7' }) + mocks.repository.mockResolvedValue({ id: '123', fullName: 'acme/platform' }) + mocks.binding.mockImplementation(async ({ sourceConfig }) => ({ + credentialGroupId: 'group', + credentialGroupOptionId: 'github-members', + sourceConfig, + })) + }) + + it.each([ + { status: 404, operation: 'repository' as const }, + { status: 422, operation: 'repository-token' as const }, + ])( + 'rejects an incompatible installation with actionable validation ($status)', + async ({ status, operation }) => { + mocks.repository.mockRejectedValue( + new GitHubInstallationError('Provider detail', status, operation) + ) + await expect( + updateKnowledgeConnectorAccess.execute({ principal, input: replacementInput }) + ).rejects.toMatchObject({ + code: 'validation', + message: + "This GitHub connection cannot access this source's repository. Choose a connection with access to the same repository, or add a new source for a different repository.", + }) + expect(mocks.repository).toHaveBeenCalledExactlyOnceWith( + { installationId: '42', accountId: '7' }, + 'acme/platform' + ) + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.binding).not.toHaveBeenCalled() + expect(mocks.token).not.toHaveBeenCalled() + } + ) + + it('rejects a recreated repository at the same path before changing the binding', async () => { + mocks.repository.mockResolvedValue({ id: '999', fullName: 'acme/platform' }) + await expect( + updateKnowledgeConnectorAccess.execute({ principal, input: replacementInput }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Create a new source to index a different GitHub repository', + }) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('passes a verified replacement for the same repository to the atomic access update', async () => { + await updateKnowledgeConnectorAccess.execute({ principal, input: replacementInput }) + expect(mocks.authorizeOrganization).toHaveBeenCalledWith( + principal, + expect.objectContaining({ minimumRole: 'admin' }), + expect.objectContaining({ organizationId: 'org' }) + ) + expect(mocks.credential).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + credentialId: replacementInput.credentialId, + scope: { kind: 'organization', organizationId: 'org' }, + }) + ) + expect(mocks.update).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + userId: 'admin', + connectorId: 'source', + knowledgeBase: { id: 'kb', name: 'Search', organizationId: 'org' }, + target: { + accessMode: 'members', + credentialId: replacementInput.credentialId, + binding: { + credentialGroupId: 'group', + credentialGroupOptionId: 'github-members', + sourceConfig, + }, + }, + }) + ) + }) + + it('rechecks organization administration before reading the installation or mutating the source', async () => { + mocks.authorizeOrganization.mockRejectedValue( + new OrchestrationError('forbidden', 'Organization administrator access is required') + ) + await expect( + updateKnowledgeConnectorAccess.execute({ principal, input: replacementInput }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.credential).not.toHaveBeenCalled() + expect(mocks.repository).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + }) + + it.each([403, 429, 503])( + 'preserves a real provider failure (%s) without changing the binding', + async (status) => { + const error = new GitHubInstallationError('Provider unavailable', status, 'repository') + mocks.repository.mockRejectedValue(error) + await expect( + updateKnowledgeConnectorAccess.execute({ principal, input: replacementInput }) + ).rejects.toBe(error) + expect(mocks.update).not.toHaveBeenCalled() + } + ) }) describe('source member enrollment', () => { diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index 3090096a08e..518d5123eeb 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -34,6 +34,7 @@ import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { type ConnectorAccessMode, mirrorsSourceAcls, + supportsConnectorAccessMode, } from '@/lib/knowledge/connectors/access-modes' import { validateKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-access' import { @@ -226,6 +227,12 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ ) } + if (!supportsConnectorAccessMode(connectorMeta, input.accessMode)) { + throw new OrchestrationError( + 'validation', + `${connectorMeta.name} requires source permission access.` + ) + } if ( context.knowledgeBase.isSearchIndex && (!connectorMeta.search || input.accessMode === 'workspace') diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index b631ac8fb93..1c60570d056 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -4,7 +4,7 @@ import { document, knowledgeConnector, member } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolveKnowledgeBase: vi.fn(), @@ -151,7 +151,9 @@ vi.mock('@/connectors/registry.server', () => ({ }, })) +import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route' import { OrchestrationError } from '@/lib/core/orchestration/types' +import * as encryption from '@/lib/core/security/encryption' import { createApprovedSearchSource, createKnowledgeConnector, @@ -165,6 +167,7 @@ import { validateConnectorSourceConfig, } from '@/lib/knowledge/application/connectors' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH } from '@/lib/knowledge/constants' +import * as githubInstallation from '@/lib/oauth/github-installation' import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' @@ -926,7 +929,7 @@ describe('knowledge connector application use cases', () => { mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) queueTableRows(document, [{ value: 5 }]) queueTableRows(document, [{ value: 2 }]) - queueTableRows(document, [{ value: 1 }]) + queueTableRows(document, [{ failed: 1, skipped: 3 }]) queueTableRows(document, [ { id: 'document-3', filename: 'c.txt', userExcluded: false }, { id: 'document-4', filename: 'd.txt', userExcluded: true }, @@ -949,7 +952,7 @@ describe('knowledge connector application use cases', () => { { id: 'document-3', filename: 'c.txt', userExcluded: false }, { id: 'document-4', filename: 'd.txt', userExcluded: true }, ], - counts: { active: 5, excluded: 2, failed: 1 }, + counts: { active: 5, excluded: 2, failed: 1, skipped: 3 }, hasMore: false, offset: 2, limit: 2, @@ -1213,6 +1216,23 @@ describe('knowledge connector application use cases', () => { expect(mocks.syncConnector).toHaveBeenCalledTimes(1) }) + it('preserves a cooldown conflict without projecting a successful sync audit', async () => { + allowOnly(['confluence']) + const message = 'Sync finished recently. Try again in 60 seconds.' + mocks.syncConnector.mockResolvedValueOnce({ + success: false, + errorCode: 'conflict', + error: message, + }) + + await expect( + syncKnowledgeConnector.execute({ principal: delegatedPrincipal, input: syncInput }) + ).rejects.toMatchObject({ code: 'conflict', message }) + + expect(mocks.syncConnector).toHaveBeenCalledOnce() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + /** * Pausing and deleting stay reachable: the point is to stop the member * re-running the pull, never to strand the connector. @@ -1634,3 +1654,190 @@ describe('organization connector credential authorization', () => { expect(mocks.validateConnectorConfig).not.toHaveBeenCalled() }) }) + +describe('GitHub installation source rejection at the application boundary', () => { + const principal = { kind: 'session', userId: 'org-admin', sessionId: 'session' } as const + const sourceConfig = { repository: 'example/private', githubRepositoryId: '123' } + const credential = { + id: 'installation-credential', + organizationId: 'org', + workspaceId: null, + providerId: 'github-app-installation', + type: 'service_account', + encryptedServiceAccountKey: 'encrypted-binding', + providerSubjectId: '42', + providerTenantId: '7', + revokedAt: null, + } + const connector = { + id: 'source', + connectorType: 'github', + credentialId: credential.id, + accessMode: 'members' as const, + sourceConfig, + encryptedApiKey: null, + } + const createInput = { + knowledgeBaseId: 'org-index', + assertedOrganizationId: 'org', + connectorType: 'github', + credentialId: credential.id, + accessMode: 'members' as const, + sourceConfig, + syncIntervalMinutes: 60, + } + const validationMessage = + 'Check that the repository is included in the selected GitHub App installation, then retry.' + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [{ role: 'admin' }]) + const context = { + organizationId: 'org', + knowledgeBaseId: 'org-index', + knowledgeBase: { id: 'org-index', name: 'Search', isSearchIndex: true }, + } + mocks.resolveKnowledgeBase.mockResolvedValue(context) + mocks.resolveConnector.mockResolvedValue({ ...context, connectorId: connector.id, connector }) + mocks.getUserPermissionConfig.mockResolvedValue(null) + mocks.authorizeOrganizationCredentialUse.mockResolvedValue({ credential }) + mocks.resolveTokenIdentity.mockResolvedValue({ kind: 'service_account' }) + mocks.resolveTokenBundle.mockResolvedValue({ accessToken: 'repository-token' }) + mocks.resolveMembersBinding.mockResolvedValue({ + credentialGroupId: 'group', + credentialGroupOptionId: 'option', + organizationId: 'org', + sourceConfig, + }) + vi.spyOn(encryption, 'decryptSecret').mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'github_app_installation', + version: 1, + appId: '1', + appClientId: 'app-client', + installationId: '42', + accountId: '7', + accountType: 'Organization', + accountLogin: 'example', + repositorySelection: 'selected', + }), + }) + vi.spyOn(githubInstallation, 'resolveGitHubInstallationRepository').mockResolvedValue({ + id: '123', + fullName: sourceConfig.repository, + defaultBranch: 'main', + }) + mocks.createConnector.mockImplementation( + async (input: { resolveAccessToken(id: string): Promise }) => { + await input.resolveAccessToken(credential.id) + throw new Error('Unexpected connector persistence') + } + ) + mocks.updateConnector.mockImplementation( + async (input: { + prepareSourceConfig( + currentConnector: typeof connector, + config: typeof sourceConfig + ): Promise + validateSourceConfig( + currentConnector: typeof connector, + config: typeof sourceConfig + ): Promise + }) => { + const prepared = await input.prepareSourceConfig(connector, sourceConfig) + await input.validateSourceConfig(connector, prepared) + throw new Error('Unexpected connector persistence') + } + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + resetDbChainMock() + }) + + it.each([ + [422, 'repository-token'], + [404, 'repository'], + ] as const)( + 'returns a safe validation response for GitHub %s during %s', + async (status, operation) => { + vi.mocked(githubInstallation.resolveGitHubInstallationRepository).mockRejectedValueOnce( + new githubInstallation.GitHubInstallationError( + 'private provider payload', + status, + operation + ) + ) + const error = await createKnowledgeConnector + .execute({ principal, input: createInput }) + .catch((error: unknown) => error) + expect(error).toBeInstanceOf(OrchestrationError) + expect(internalOrchestrationErrorPolicy.project(error)).toMatchObject({ + status: 400, + body: { error: validationMessage }, + }) + expect(mocks.authorizeOrganizationCredentialUse).toHaveBeenCalledWith( + expect.objectContaining({ principal, organizationId: 'org', credentialId: credential.id }) + ) + expect(mocks.createConnector).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + + it.each(['create', 'update'] as const)( + 'classifies a repository token rejected during authorized %s validation', + async (operation) => { + mocks.resolveTokenBundle.mockRejectedValueOnce( + new githubInstallation.GitHubInstallationError( + 'private token response', + 422, + 'repository-token' + ) + ) + const result = + operation === 'create' + ? createKnowledgeConnector.execute({ principal, input: createInput }) + : updateKnowledgeConnector.execute({ + principal, + input: { + knowledgeBaseId: 'org-index', + connectorId: connector.id, + updates: { sourceConfig }, + }, + }) + await expect(result).rejects.toMatchObject({ code: 'validation', message: validationMessage }) + expect(mocks.resolveTokenBundle).toHaveBeenCalledOnce() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + + it.each([403, 429, 503])( + 'preserves GitHub %s and its operation for retry classification', + async (status) => { + const failure = new githubInstallation.GitHubInstallationError( + 'GitHub provider request failed', + status, + 'repository-token' + ) + mocks.resolveTokenBundle.mockRejectedValueOnce(failure) + await expect( + createKnowledgeConnector.execute({ principal, input: createInput }) + ).rejects.toBe(failure) + expect(internalOrchestrationErrorPolicy.project(failure)).toBeNull() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + + it('preserves network failures during authorized source creation', async () => { + const failure = new TypeError('Network request failed') + mocks.resolveTokenBundle.mockRejectedValueOnce(failure) + await expect(createKnowledgeConnector.execute({ principal, input: createInput })).rejects.toBe( + failure + ) + expect(internalOrchestrationErrorPolicy.project(failure)).toBeNull() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index ab8f4a2ff5a..f4deff13f95 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -43,12 +43,14 @@ import { resolveActiveKnowledgeResourceContext, resolveKnowledgeWorkspaceContext, } from '@/lib/knowledge/application/contexts' +import { rethrowGitHubInstallationSourceError } from '@/lib/knowledge/application/github-installation-error' import { prepareGitHubInstallationSource } from '@/lib/knowledge/application/github-installation-source' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { type ConnectorAccessMode, isConnectorAccessMode, mirrorsSourceAcls, + supportsConnectorAccessMode, } from '@/lib/knowledge/connectors/access-modes' import { type ConnectorAccessToken, @@ -61,6 +63,16 @@ import { type ViewerConnectorMembership, } from '@/lib/knowledge/connectors/member-provisioning' import { assertConnectorMirrorsSourceAcls } from '@/lib/knowledge/connectors/mirrored-access' +import type { + ConnectorPermissionConfig, + PreparedConnectorPermissions, +} from '@/lib/knowledge/connectors/permission-config' +import { + hasConnectorPermissionConfig, + prepareConnectorPermissions, + readConnectorPermissionSummaries, + readConnectorPermissionSummary, +} from '@/lib/knowledge/connectors/permission-config.server' import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits' import { DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, @@ -68,6 +80,11 @@ import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH, } from '@/lib/knowledge/constants' +import { + documentProcessingOutcomeSelection, + failedDocumentCondition, + skippedDocumentCondition, +} from '@/lib/knowledge/documents/processing-status' import { type ResolvedMembersBinding, resolveKnowledgeConnectorMembersBinding, @@ -129,6 +146,7 @@ export interface CreateKnowledgeConnectorInput extends KnowledgeConnectorApplica connectorType: string credentialId?: string apiKey?: string + permissionConfig?: ConnectorPermissionConfig sourceConfig: Record syncIntervalMinutes: number /** @@ -144,6 +162,8 @@ export interface CreateKnowledgeConnectorInput extends KnowledgeConnectorApplica export interface UpdateKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { connectorId: string updates: { + apiKey?: string + permissionConfig?: ConnectorPermissionConfig sourceConfig?: Record syncIntervalMinutes?: number status?: 'active' | 'paused' @@ -308,7 +328,7 @@ export async function resolveConnectorCredentialAccessToken(input: { userId: identity.kind === 'oauth' ? identity.userId : input.actingUserId, requestId: input.requestId, sourceConfig: input.sourceConfig, - }) + }).catch(rethrowGitHubInstallationSourceError) return resolved } @@ -320,6 +340,7 @@ export async function validateConnectorSourceConfig(input: { organizationId?: string actingUserId: string requestId: string + permissionChange?: PreparedConnectorPermissions }): Promise { const accessMode = input.connector.accessMode if (!isConnectorAccessMode(accessMode)) { @@ -405,7 +426,7 @@ export async function validateConnectorSourceConfig(input: { userId: tokenUserId, requestId: input.requestId, sourceConfig: input.sourceConfig, - }) + }).catch(rethrowGitHubInstallationSourceError) if (!resolved) { return { message: 'Failed to refresh access token. Please reconnect your account.', @@ -413,14 +434,25 @@ export async function validateConnectorSourceConfig(input: { } } + const validationContext = { + ...syncContextForToken(resolved), + mirrorsSourceAcls: mirrorsSourceAcls(input.connector.accessMode), + ...(input.connector.accessMode === 'members' ? PER_MEMBER_LISTING_CONTEXT : {}), + } + if (validationContext.mirrorsSourceAcls) { + if (input.permissionChange) { + input.permissionChange.populateSyncContext(validationContext, input.connector.id) + } else { + await connectorConfig.permissionConfig?.populateSyncContext( + input.connector.id, + validationContext + ) + } + } const validation = await connectorConfig.validateConfig( resolved.accessToken, input.sourceConfig, - { - ...syncContextForToken(resolved), - mirrorsSourceAcls: mirrorsSourceAcls(input.connector.accessMode), - ...(input.connector.accessMode === 'members' ? PER_MEMBER_LISTING_CONTEXT : {}), - } + validationContext ) return validation.valid ? null @@ -462,6 +494,7 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({ : await orderedQuery.limit(input.limit + 1).offset(offset) const hasMore = input.limit !== undefined && rows.length > input.limit const page = input.limit === undefined ? rows : rows.slice(0, input.limit) + const permissionSummaries = await readConnectorPermissionSummaries(page) const viewerUserId = principal.kind === 'session' ? principal.userId : null const memberships = viewerUserId && (context.workspaceId || context.organizationId) @@ -475,6 +508,7 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({ return { connectors: page.map(({ encryptedApiKey: _encryptedApiKey, ...rest }) => ({ ...rest, + permissionConfig: permissionSummaries.get(rest.id), viewerMembership: memberships.get(rest.id) ?? null, })), hasMore, @@ -647,6 +681,10 @@ export const readKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ return { connector: { ...connectorData, + permissionConfig: await readConnectorPermissionSummary( + connector.connectorType, + connector.id + ), viewerMembership: memberships.get(connector.id) ?? null, syncLogs, memberSyncLogs, @@ -713,6 +751,12 @@ async function executeCreateKnowledgeConnector( if (!connectorMeta) { throw new OrchestrationError('validation', `Unknown connector type: ${input.connectorType}`) } + if (!supportsConnectorAccessMode(connectorMeta, input.accessMode ?? 'workspace')) { + throw new OrchestrationError( + 'validation', + `${connectorMeta.name} requires source permission access.` + ) + } if ( input.apiKey && (!getConnectorApiKeyConfig(connectorMeta.auth) || input.accessMode === 'members') @@ -792,11 +836,20 @@ async function executeCreateKnowledgeConnector( sourceConfig: membersBinding?.sourceConfig ?? input.sourceConfig, }) if (membersBinding) membersBinding = { ...membersBinding, sourceConfig } + const permissionChange = input.permissionConfig + ? await prepareConnectorPermissions(input.connectorType, { + accessMode: input.accessMode ?? 'workspace', + sourceConfig, + permissionConfig: input.permissionConfig, + apiKey: input.apiKey, + }) + : undefined const outcome = await performCreateKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorType: input.connectorType, credentialId: input.credentialId, apiKey: input.apiKey, + permissionChange, /** Members mode stores the config with its listing caps cleared. */ sourceConfig, syncIntervalMinutes: input.syncIntervalMinutes, @@ -826,7 +879,13 @@ async function executeCreateKnowledgeConnector( }) requireSuccessfulOutcome(outcome, 'Knowledge connector creation failed') return { - connector: outcome.connector, + connector: { + ...outcome.connector, + permissionConfig: await readConnectorPermissionSummary( + outcome.connector.connectorType, + outcome.connector.id + ), + }, workspaceId, ...(outcome.reused ? { reused: true } : {}), } @@ -952,10 +1011,64 @@ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ async execute({ principal, input, context, request }) { const requestId = generateRequestId() const actingUserId = resolveKnowledgeAttributedUserId(principal, context) + const { apiKey, permissionConfig, ...updates } = input.updates + let permissionChange: PreparedConnectorPermissions | undefined + let expectedUpdatedAt: Date | undefined + if ( + permissionConfig || + apiKey !== undefined || + (updates.sourceConfig !== undefined && + (await hasConnectorPermissionConfig(context.connector.connectorType))) + ) { + if (permissionConfig || apiKey !== undefined) { + const subjectUserId = resolvePrincipalSubjectUserId(principal) + if (!subjectUserId) + throw new OrchestrationError( + 'forbidden', + 'Permission settings require a signed-in administrator.' + ) + if (context.organizationId) + await requireOrganizationMembership( + principal, + context.organizationId, + 'admin', + 'knowledge.use' + ) + else if (context.workspaceId) await requireCurrentHumanRole(subjectUserId, context, 'admin') + } + const connector = await getKnowledgeConnector(context.knowledgeBaseId, context.connectorId) + if (!connector) throw new OrchestrationError('not_found', 'Connector not found') + expectedUpdatedAt = connector.updatedAt + permissionChange = await prepareConnectorPermissions(connector.connectorType, { + accessMode: connector.accessMode, + sourceConfig: updates.sourceConfig ?? (connector.sourceConfig as Record), + permissionConfig, + apiKey, + existing: connector, + }) + if (permissionChange && !permissionConfig && apiKey === undefined) { + const subjectUserId = resolvePrincipalSubjectUserId(principal) + if (!subjectUserId) + throw new OrchestrationError( + 'forbidden', + 'Permission settings require a signed-in administrator.' + ) + if (context.organizationId) + await requireOrganizationMembership( + principal, + context.organizationId, + 'admin', + 'knowledge.use' + ) + else if (context.workspaceId) await requireCurrentHumanRole(subjectUserId, context, 'admin') + } + } const outcome = await performUpdateKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorId: context.connectorId, - updates: input.updates, + updates, + permissionChange, + expectedUpdatedAt, prepareSourceConfig: (connector, sourceConfig) => prepareGitHubInstallationSource({ principal, @@ -981,7 +1094,10 @@ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ const owner = resourceScopeFields(resourceScopeFromOwner(context)) return validateConnectorSourceConfig({ principal, - connector, + connector: permissionChange?.encryptedApiKey + ? { ...connector, encryptedApiKey: permissionChange.encryptedApiKey } + : connector, + permissionChange, sourceConfig, ...owner, actingUserId, @@ -995,7 +1111,15 @@ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ recordSemanticAudit: false, }) requireSuccessfulOutcome(outcome, 'Knowledge connector update failed') - return { connector: outcome.connector } + return { + connector: { + ...outcome.connector, + permissionConfig: await readConnectorPermissionSummary( + outcome.connector.connectorType, + outcome.connector.id + ), + }, + } }, projectAudit: ({ input, context, result }) => ({ action: AuditAction.CONNECTOR_UPDATED, @@ -1150,6 +1274,8 @@ const connectorDocumentSelection = { userExcluded: document.userExcluded, uploadedAt: document.uploadedAt, processingStatus: document.processingStatus, + processingOutcome: documentProcessingOutcomeSelection(), + processingError: document.processingError, } export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase({ @@ -1198,7 +1324,7 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase( ? sql`${document.filename} ILIKE ${`%${escapeLikePattern(search)}%`} ESCAPE '\\'` : undefined, ] as const - const [[activeCount], excludedCountRows, [failedCount]] = await Promise.all([ + const [[activeCount], excludedCountRows, [outcomeCounts]] = await Promise.all([ db .select({ value: count() }) .from(document) @@ -1210,15 +1336,12 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase( .where(and(...baseConditions, eq(document.userExcluded, true))) : Promise.resolve([{ value: 0 }]), db - .select({ value: count() }) + .select({ + failed: sql`count(*) FILTER (WHERE ${failedDocumentCondition()})::int`, + skipped: sql`count(*) FILTER (WHERE ${skippedDocumentCondition()})::int`, + }) .from(document) - .where( - and( - ...baseConditions, - eq(document.userExcluded, false), - eq(document.processingStatus, 'failed') - ) - ), + .where(and(...baseConditions, eq(document.userExcluded, false))), ]) const excludedCount = excludedCountRows[0] const rows = await db @@ -1228,7 +1351,8 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase( and( ...baseConditions, filter ? eq(document.userExcluded, filter === 'excluded') : undefined, - filter === 'failed' ? eq(document.processingStatus, 'failed') : undefined + filter === 'failed' ? failedDocumentCondition() : undefined, + filter === 'skipped' ? skippedDocumentCondition() : undefined ) ) .orderBy(asc(document.userExcluded), asc(document.filename), asc(document.id)) @@ -1241,7 +1365,8 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase( counts: { active: activeCount?.value ?? 0, excluded: excludedCount?.value ?? 0, - failed: failedCount?.value ?? 0, + failed: outcomeCounts?.failed ?? 0, + skipped: outcomeCounts?.skipped ?? 0, }, hasMore, offset, diff --git a/apps/sim/lib/knowledge/application/github-installation-error.ts b/apps/sim/lib/knowledge/application/github-installation-error.ts new file mode 100644 index 00000000000..bad83142274 --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-installation-error.ts @@ -0,0 +1,22 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' + +/** Maps repository selection refusals while preserving provider failures for their caller. */ +export function rethrowGitHubInstallationSourceError( + error: unknown, + { existingSource = false }: { existingSource?: boolean } = {} +): never { + if ( + error instanceof GitHubInstallationError && + ((error.operation === 'repository-token' && error.status === 422) || + (error.operation === 'repository' && error.status === 404)) + ) { + throw new OrchestrationError( + 'validation', + existingSource + ? "This GitHub connection cannot access this source's repository. Choose a connection with access to the same repository, or add a new source for a different repository." + : 'Check that the repository is included in the selected GitHub App installation, then retry.' + ) + } + throw error +} diff --git a/apps/sim/lib/knowledge/application/github-installation-source.test.ts b/apps/sim/lib/knowledge/application/github-installation-source.test.ts index 2c7666faf04..f68d58c33b8 100644 --- a/apps/sim/lib/knowledge/application/github-installation-source.test.ts +++ b/apps/sim/lib/knowledge/application/github-installation-source.test.ts @@ -12,6 +12,7 @@ vi.mock('@/lib/knowledge/application/connector-credential', () => ({ })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: m.decrypt })) vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error {}, parseGitHubInstallationBinding: m.parse, resolveGitHubInstallationRepository: m.repository, })) diff --git a/apps/sim/lib/knowledge/application/github-installation-source.ts b/apps/sim/lib/knowledge/application/github-installation-source.ts index 8ee2daa57b4..788216aac32 100644 --- a/apps/sim/lib/knowledge/application/github-installation-source.ts +++ b/apps/sim/lib/knowledge/application/github-installation-source.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret } from '@/lib/core/security/encryption' import { requireConnectorCredential } from '@/lib/knowledge/application/connector-credential' +import { rethrowGitHubInstallationSourceError } from '@/lib/knowledge/application/github-installation-error' import { parseGitHubInstallationBinding, resolveGitHubInstallationRepository, @@ -80,7 +81,9 @@ export async function prepareGitHubInstallationSource( binding.accountId !== contentCredential.providerTenantId ) throw new OrchestrationError('validation', 'Reconnect this GitHub installation before using it') - const resolved = await resolveGitHubInstallationRepository(binding, repository.trim()) + const resolved = await resolveGitHubInstallationRepository(binding, repository.trim()).catch( + (error) => rethrowGitHubInstallationSourceError(error, { existingSource: wasInstallation }) + ) if (wasInstallation && input.previousConfig?.githubRepositoryId !== resolved.id) throw new OrchestrationError( 'validation', diff --git a/apps/sim/lib/knowledge/application/github-installations.test.ts b/apps/sim/lib/knowledge/application/github-installations.test.ts index 645180d446e..8e6355ffc27 100644 --- a/apps/sim/lib/knowledge/application/github-installations.test.ts +++ b/apps/sim/lib/knowledge/application/github-installations.test.ts @@ -33,11 +33,30 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: async () => null, })) vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error { + constructor( + message: string, + readonly status?: number + ) { + super(message) + } + }, getGitHubInstallationConfiguration: m.configuration, listUserAdminGitHubInstallations: m.list, verifyGitHubInstallationBinding: m.verify, })) -vi.mock('@/lib/credentials/managed-oauth', () => ({ resolveManagedOAuthToken: m.token })) +vi.mock('@/lib/credentials/managed-oauth', () => ({ + resolveManagedOAuthToken: m.token, + ManagedOAuthCredentialError: class extends Error { + constructor( + readonly code: string, + message: string, + readonly statusCode: number + ) { + super(message) + } + }, +})) vi.mock('@/lib/credential-groups/provider-registry', () => ({ getCredentialGroupProviderAdapter: () => ({ getPolicy: async () => ({ authorizationAppId: 'current-app', scopeVersion: 1 }), @@ -45,10 +64,12 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ })) vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: m.encrypt })) +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' import { connectGitHubSearchInstallation, listGitHubSearchInstallations, } from '@/lib/knowledge/application/github-installations' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const const input = { organizationId: 'org', installationId: '42' } @@ -142,6 +163,56 @@ describe('GitHub Search installation application operations', () => { }) expect(m.list).toHaveBeenCalledWith('ghu_reader', { signal }) }) + it('reenters reader OAuth when GitHub revoked a token still recorded as active', async () => { + setupReader() + m.list.mockRejectedValueOnce(new GitHubInstallationError('Bad credentials', 401)) + await expect( + listGitHubSearchInstallations.execute({ principal, input }) + ).resolves.toMatchObject({ + needsUserConnection: true, + installations: [], + }) + }) + it('reenters reader OAuth when token refresh detects a revoked grant', async () => { + setupReader() + m.token.mockRejectedValueOnce( + new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Refresh grant revoked', + 401 + ) + ) + await expect( + listGitHubSearchInstallations.execute({ principal, input }) + ).resolves.toMatchObject({ + needsUserConnection: true, + installations: [], + }) + expect(m.list).not.toHaveBeenCalled() + }) + it('preserves provider infrastructure failures during discovery', async () => { + setupReader() + const error = new GitHubInstallationError('Provider unavailable', 503) + m.list.mockRejectedValueOnce(error) + await expect(listGitHubSearchInstallations.execute({ principal, input })).rejects.toBe(error) + }) + it('preserves transient token refresh failures instead of requesting OAuth', async () => { + setupReader() + const error = new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REFRESH_FAILED', + 'Transient refresh error', + 502 + ) + m.token.mockRejectedValueOnce(error) + await expect(listGitHubSearchInstallations.execute({ principal, input })).rejects.toBe(error) + }) + it('does not reclassify App JWT authorization failures during installation verification', async () => { + setupReader() + const error = new GitHubInstallationError('App JWT rejected', 401) + m.verify.mockRejectedValueOnce(error) + await expect(connect()).rejects.toBe(error) + expect(m.encrypt).not.toHaveBeenCalled() + }) it('reverifies GitHub admin authority before persisting an installation', async () => { setupReader() m.verify.mockRejectedValue(new Error('GitHub administrator access required')) @@ -155,7 +226,7 @@ describe('GitHub Search installation application operations', () => { const result = await connect() expect(result).toMatchObject({ created: true, - credential: { displayName: 'GitHub App · example' }, + credential: { displayName: 'example' }, }) expect(m.verify).toHaveBeenCalledWith('ghu_reader', '42', { signal: undefined }) expect(m.encrypt).toHaveBeenCalledWith(JSON.stringify(binding)) diff --git a/apps/sim/lib/knowledge/application/github-installations.ts b/apps/sim/lib/knowledge/application/github-installations.ts index 917e9569cfb..7add172b1c2 100644 --- a/apps/sim/lib/knowledge/application/github-installations.ts +++ b/apps/sim/lib/knowledge/application/github-installations.ts @@ -13,18 +13,25 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { LIVE_ENROLLMENT_STATUSES } from '@/lib/credential-groups/credentials' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' -import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' +import { + ManagedOAuthCredentialError, + resolveManagedOAuthToken, +} from '@/lib/credentials/managed-oauth' import type { DbOrTx } from '@/lib/db/types' import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { + GitHubInstallationError, getGitHubInstallationConfiguration, listUserAdminGitHubInstallations, verifyGitHubInstallationBinding, } from '@/lib/oauth/github-installation' -import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' +import { + GITHUB_INSTALLATION_PROVIDER_ID, + type GitHubInstallationSummary, +} from '@/lib/oauth/github-installation-types' interface InstallationInput { organizationId: string @@ -36,7 +43,11 @@ interface ConnectInstallationInput extends InstallationInput { } /** Selects only the acting person's live, organization-bound GitHub connection. */ -async function findReaderCredential(executor: DbOrTx, organizationId: string, userId: string) { +export async function findGitHubSearchReaderCredential( + executor: DbOrTx, + organizationId: string, + userId: string +) { const policy = await getCredentialGroupProviderAdapter('github-repositories').getPolicy( undefined, { organizationId } @@ -99,18 +110,31 @@ export const listGitHubSearchInstallations = defineAuthorizedKnowledgeUseCase({ await requireOrganizationSearchAvailable(context.organizationId) const configuration = getGitHubInstallationConfiguration() const reader = configuration.configured - ? await findReaderCredential(db, context.organizationId, principal.userId) + ? await findGitHubSearchReaderCredential(db, context.organizationId, principal.userId) : null - const installations = reader - ? await listUserAdminGitHubInstallations( + let needsUserConnection = configuration.configured && !reader + let installations: GitHubInstallationSummary[] = [] + if (reader) { + try { + installations = await listUserAdminGitHubInstallations( (await readerToken(context.organizationId, reader.id)).accessToken, { signal: input.signal } ) - : [] + } catch (error) { + /** Only reader-token discovery can request reauthorization; App JWT failures stay errors. */ + if ( + (error instanceof GitHubInstallationError && error.status === 401) || + (error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH') + ) + needsUserConnection = true + else throw error + } + } return { available: configuration.configured, installUrl: configuration.installUrl, - needsUserConnection: configuration.configured && !reader, + needsUserConnection, installations, } }, @@ -127,7 +151,11 @@ export const connectGitHubSearchInstallation = defineAuthorizedKnowledgeUseCase( 'validation', 'GitHub App installation indexing is not configured for this environment' ) - const reader = await findReaderCredential(db, context.organizationId, principal.userId) + const reader = await findGitHubSearchReaderCredential( + db, + context.organizationId, + principal.userId + ) if (!reader) throw new OrchestrationError( 'validation', @@ -168,7 +196,11 @@ export const connectGitHubSearchInstallation = defineAuthorizedKnowledgeUseCase( ) .for('update') .limit(1) - const current = await findReaderCredential(tx, context.organizationId, principal.userId) + const current = await findGitHubSearchReaderCredential( + tx, + context.organizationId, + principal.userId + ) if ( current?.id !== reader.id || current.authorizationAppId !== reader.authorizationAppId || @@ -194,7 +226,7 @@ export const connectGitHubSearchInstallation = defineAuthorizedKnowledgeUseCase( .limit(1) const id = existing?.id ?? generateId() const now = new Date() - const displayName = `GitHub App · ${binding.accountLogin}` + const displayName = binding.accountLogin const values = { displayName, encryptedServiceAccountKey: encrypted, diff --git a/apps/sim/lib/knowledge/application/github-setup.test.ts b/apps/sim/lib/knowledge/application/github-setup.test.ts new file mode 100644 index 00000000000..bd1d9939517 --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-setup.test.ts @@ -0,0 +1,608 @@ +/** @vitest-environment node */ +import { db } from '@sim/db' +import { credential, member, user } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => { + const values = new Map() + return { + values, + get: vi.fn(async (key: string) => { + const entry = values.get(key) + return entry && entry.expires > Date.now() ? entry.value : null + }), + set: vi.fn( + async (key: string, value: string, _unit: string, ttl: number, condition: string) => { + const entry = values.get(key) + const exists = entry && entry.expires > Date.now() + if ((condition === 'NX' && exists) || (condition === 'XX' && !exists)) return null + values.set(key, { value, expires: Date.now() + ttl }) + return 'OK' + } + ), + eval: vi.fn( + async ( + _script: string, + _count: number, + key: string, + nextValue: string, + expected: string, + ttl: number + ) => { + const entry = values.get(key) + if (!entry || entry.expires <= Date.now()) return 0 + const current = JSON.parse(entry.value) as Record + const next = JSON.parse(nextValue) as Record + if ( + current.phase !== expected || + ['organizationId', 'userId', 'sessionId', 'setupId', 'createdAt'].some( + (field) => current[field] !== next[field] + ) + ) + return 0 + values.set(key, { value: nextValue, expires: Date.now() + ttl }) + return 1 + } + ), + list: vi.fn(), + connect: vi.fn(), + reader: vi.fn(), + receipt: vi.fn(), + provision: vi.fn(), + enrollment: vi.fn(), + oauthContext: vi.fn(), + oauthStart: vi.fn(), + oauthComplete: vi.fn(), + } +}) +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: () => ({ get: m.get, set: m.set, eval: m.eval }), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.example' })) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeOrganizationContext: async ({ organizationId }: { organizationId: string }) => ({ + organizationId, + workspaceId: undefined, + }), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: async () => null, +})) +vi.mock('@/lib/knowledge/application/github-installations', () => ({ + listGitHubSearchInstallations: { execute: m.list }, + connectGitHubSearchInstallation: { execute: m.connect }, + findGitHubSearchReaderCredential: m.reader, +})) +vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ + provisionKnowledgeConnectorMembersBinding: m.provision, +})) +vi.mock('@/lib/credential-groups/self-enrollment', () => ({ + createViewerCredentialGroupEnrollment: m.enrollment, +})) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + getCredentialGroupOAuthContextForEnrollment: m.oauthContext, +})) +vi.mock('@/lib/credential-groups/oauth', () => ({ startCredentialGroupOAuth: m.oauthStart })) +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + completePublicCredentialGroupOAuth: { execute: m.oauthComplete }, +})) +vi.mock('@/lib/credential-groups/search-connection-completion', () => ({ + readSearchConnectionCompletion: m.receipt, +})) +vi.mock('@/connectors/registry', () => ({ + CONNECTOR_META_REGISTRY: { + github: { name: 'GitHub', auth: { mode: 'oauth', provider: 'github-repositories' } }, + }, +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ + ManagedOAuthCredentialError: class extends Error {}, +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error {}, + getGitHubInstallationConfiguration: () => ({ + configured: true, + installUrl: 'https://github.com/apps/test-search/installations/new', + }), +})) + +import { + cancelGitHubSearchSetup, + completeGitHubSearchSetup, + completeGitHubSetupReaderOAuth, + continueGitHubSearchSetup, + readGitHubSearchSetup, + selectGitHubSearchSetup, + startGitHubSearchSetup, +} from '@/lib/knowledge/application/github-setup' +import { + GITHUB_SETUP_TTL_MS, + readGitHubSetupAttempt, + saveGitHubSetupAttempt, +} from '@/lib/knowledge/github-setup-state' + +const principal = { kind: 'session', userId: 'admin', sessionId: 'browser-1' } as const +const input = { organizationId: 'organization', setupId: '550e8400-e29b-41d4-a716-446655440000' } +const scope = { ...input, userId: principal.userId, sessionId: principal.sessionId } +const installation = { + installationId: '42', + accountId: '7', + accountLogin: 'example', + accountType: 'Organization' as const, +} +const secondInstallation = { + ...installation, + installationId: '43', + accountId: '8', + accountLogin: 'another', +} +const reader = { id: 'reader' } +const resultCredential = { id: 'installation-credential', displayName: 'example' } +function admin(role = 'admin') { + queueTableRows(member, [{ role }]) +} +async function start(intent?: 'install') { + admin() + return startGitHubSearchSetup.execute({ + principal, + input: { ...input, ...(intent ? { intent } : {}) }, + }) +} +async function status() { + admin() + return readGitHubSearchSetup.execute({ principal, input }) +} +async function cancel() { + admin() + return cancelGitHubSearchSetup.execute({ principal, input }) +} +async function callback(state: string, installationId = '42') { + admin() + return completeGitHubSearchSetup.execute({ + principal, + input: { state, installationId, setupAction: 'install' }, + }) +} +async function resume() { + admin() + return continueGitHubSearchSetup.execute({ principal, input }) +} +const oauthAttempt = { + state: 'cg_fixture', + provider: 'github-repositories' as const, + userId: principal.userId, + organizationId: input.organizationId, + email: 'admin@example.test', + credentialGroupId: 'group', + enrollmentId: 'enrollment', + optionId: 'option', + authorizationAppId: 'app', + scopeVersion: 1, + nonceHash: 'hash', + requiredScopes: [], + redirectUri: 'https://sim.example/api/auth/oauth2/callback/github-repositories', + completionRedirect: true, + completionId: input.setupId, + returnTo: 'github-installation' as const, + invitationToken: 'fixture-invitation', + createdAt: 0, +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + m.values.clear() + m.list.mockResolvedValue({ available: true, needsUserConnection: false, installations: [] }) + m.connect.mockResolvedValue({ credential: resultCredential, created: true }) + m.reader.mockResolvedValue(reader) + m.receipt.mockResolvedValue(null) + m.provision.mockResolvedValue({ credentialGroupId: 'group', credentialGroupOptionId: 'option' }) + m.enrollment.mockResolvedValue({ + enrollment: { id: 'enrollment', email: 'admin@example.test' }, + invitationLink: 'https://sim.example/credential-groups/enroll/fixture-invitation', + }) + m.oauthContext.mockResolvedValue({ fixture: 'oauth-context' }) + m.oauthStart.mockResolvedValue('https://github.com/login/oauth/authorize?state=cg_fixture') + m.oauthComplete.mockResolvedValue({ credentialId: 'reader' }) +}) +afterEach(() => { + vi.useRealTimers() + resetDbChainMock() +}) + +describe('GitHub setup lifecycle', () => { + it.each(['member', 'missing'])( + 'requires current organization admin access before state or provider work: %s', + async (role) => { + queueTableRows(member, role === 'missing' ? [] : [{ role }]) + await expect(startGitHubSearchSetup.execute({ principal, input })).rejects.toMatchObject({ + code: role === 'missing' ? 'not_found' : 'forbidden', + }) + expect(m.set).not.toHaveBeenCalled() + expect(m.list).not.toHaveBeenCalled() + } + ) + it('refuses non-session principals before state lookup', async () => { + await expect( + startGitHubSearchSetup.execute({ + principal: { kind: 'personal_api_key', userId: 'admin', keyId: 'key' }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(m.get).not.toHaveBeenCalled() + }) + it('issues opaque state, verifies installation binding through the existing operation, and makes refresh idempotent', async () => { + const { url } = await start() + const state = new URL(url).searchParams.get('state')! + expect(state).not.toBe(input.setupId) + expect(url).not.toContain(input.organizationId) + expect(new URL(url).pathname).toBe('/apps/test-search/installations/new') + await callback(state) + expect(m.connect).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: expect.objectContaining({ + organizationId: input.organizationId, + installationId: '42', + }), + }) + ) + await expect(status()).resolves.toEqual({ status: 'completed', credential: resultCredential }) + await callback(state) + expect(m.connect).toHaveBeenCalledTimes(1) + }) + it('reuses a sole eligible installation without forcing GitHub configuration', async () => { + m.list.mockResolvedValue({ + available: true, + needsUserConnection: false, + installations: [installation], + }) + const { url } = await start() + expect(new URL(url).pathname).toBe('/credential-groups/complete') + expect(new URL(url).searchParams.get('completionId')).toBe(input.setupId) + await expect(status()).resolves.toEqual({ status: 'completed', credential: resultCredential }) + }) + it('preserves explicit install intent even when one installation exists', async () => { + m.list.mockResolvedValue({ + available: true, + needsUserConnection: false, + installations: [installation], + }) + const { url } = await start('install') + expect(new URL(url).hostname).toBe('github.com') + expect(m.connect).not.toHaveBeenCalled() + }) + it('offers multiple installations using bounded metadata and rejects choices not offered', async () => { + m.list.mockResolvedValue({ + available: true, + needsUserConnection: false, + installations: [{ ...installation, appClientId: 'private-extra' }, secondInstallation], + }) + expect(new URL((await start()).url).pathname).toBe('/knowledge/github/setup') + await expect(status()).resolves.toEqual({ + status: 'choosing', + installations: [installation, secondInstallation], + }) + admin() + await expect( + selectGitHubSearchSetup.execute({ + principal, + input: { ...input, action: { kind: 'select', installationId: '999' } }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(m.connect).not.toHaveBeenCalled() + admin() + await selectGitHubSearchSetup.execute({ + principal, + input: { ...input, action: { kind: 'select', installationId: '43' } }, + }) + expect(m.connect).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ installationId: '43' }) }) + ) + }) + it('status reads never poll GitHub or return OAuth/state material', async () => { + await start() + m.list.mockClear() + await expect(status()).resolves.toEqual({ status: 'pending' }) + expect(m.list).not.toHaveBeenCalled() + expect(m.reader).not.toHaveBeenCalled() + }) + it.each([{ userId: 'other-user' }, { sessionId: 'other-browser' }])( + 'cannot read or consume another initiating session: %s', + async (change) => { + const state = new URL((await start()).url).searchParams.get('state')! + const other = { ...principal, ...change } + admin() + await expect(readGitHubSearchSetup.execute({ principal: other, input })).resolves.toEqual({ + status: 'expired', + }) + await expect( + completeGitHubSearchSetup.execute({ + principal: other, + input: { state, installationId: '42' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(m.connect).not.toHaveBeenCalled() + await callback(state) + } + ) + it('keeps organization scopes isolated even when the correlation ID is known', async () => { + await start() + admin() + await expect( + readGitHubSearchSetup.execute({ + principal, + input: { ...input, organizationId: 'another-org' }, + }) + ).resolves.toEqual({ status: 'expired' }) + }) + it('cancellation prevents late callbacks and repeated begin with the same ID', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + await cancel() + await expect(callback(state)).rejects.toMatchObject({ code: 'validation' }) + await expect(start()).rejects.toMatchObject({ code: 'validation' }) + await expect(status()).resolves.toEqual({ status: 'expired' }) + expect(m.connect).not.toHaveBeenCalled() + }) + it('a cancellation arriving before start leaves a tombstone', async () => { + await cancel() + await expect(start()).rejects.toMatchObject({ code: 'validation' }) + expect(m.list).not.toHaveBeenCalled() + }) + it('cancellation after committed completion preserves the result', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + await callback(state) + await cancel() + await expect(status()).resolves.toEqual({ status: 'completed', credential: resultCredential }) + }) + it('expires without a sliding refresh window', async () => { + vi.useFakeTimers() + const { url } = await start() + vi.advanceTimersByTime(GITHUB_SETUP_TTL_MS - 1) + await expect(status()).resolves.toEqual({ status: 'pending' }) + vi.advanceTimersByTime(1) + await expect(status()).resolves.toEqual({ status: 'expired' }) + await expect(callback(new URL(url).searchParams.get('state')!)).rejects.toMatchObject({ + code: 'validation', + }) + expect(m.connect).not.toHaveBeenCalled() + }) + it('rechecks current administrator access on the callback', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + admin('member') + await expect( + completeGitHubSearchSetup.execute({ principal, input: { state, installationId: '42' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(m.connect).not.toHaveBeenCalled() + }) + it('records a safe failure and never exposes provider response bodies', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + m.connect.mockRejectedValueOnce(new Error('secret_token fixture@example.test provider-body')) + await callback(state) + await expect(status()).resolves.toEqual({ + status: 'failed', + error: 'GitHub setup could not finish. Try again.', + }) + expect(JSON.stringify([...m.values.values()])).not.toContain('secret_token') + }) + it('an installation request awaiting GitHub approval cannot create a credential', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + admin() + await completeGitHubSearchSetup.execute({ principal, input: { state, setupAction: 'request' } }) + expect(m.connect).not.toHaveBeenCalled() + await expect(status()).resolves.toMatchObject({ status: 'failed' }) + }) +}) + +describe('GitHub setup reader OAuth continuation', () => { + async function startOAuth(intent?: 'install') { + m.list.mockResolvedValueOnce({ available: true, needsUserConnection: true, installations: [] }) + queueTableRows(credential, []) + return start(intent) + } + it('starts the existing OAuth boundary and requires its receipt plus a current reader to continue', async () => { + await startOAuth() + expect(m.oauthStart).toHaveBeenCalledWith({ fixture: 'oauth-context' }, 'fixture-invitation', { + completionRedirect: true, + completionId: input.setupId, + returnTo: 'github-installation', + connectionIntent: { kind: 'create' }, + }) + await expect(resume()).rejects.toMatchObject({ code: 'forbidden' }) + m.receipt.mockResolvedValue('wrong-reader') + await expect(resume()).rejects.toMatchObject({ code: 'forbidden' }) + m.receipt.mockResolvedValue(reader.id) + expect(new URL((await resume()).url).hostname).toBe('github.com') + }) + it('preserves explicit install intent after OAuth with existing installations', async () => { + await startOAuth('install') + m.receipt.mockResolvedValue(reader.id) + m.list.mockResolvedValue({ + available: true, + needsUserConnection: false, + installations: [installation], + }) + expect(new URL((await resume()).url).hostname).toBe('github.com') + expect(m.connect).not.toHaveBeenCalled() + }) + it('a revoked enrollment stays denied before provider authorization', async () => { + m.enrollment.mockRejectedValueOnce(new Error('revoked')) + await startOAuth() + expect(m.oauthStart).not.toHaveBeenCalled() + await expect(status()).resolves.toMatchObject({ status: 'failed' }) + }) + it('requires the exact OAuth attempt and verified current user before completing the reader grant', async () => { + await startOAuth() + admin() + await expect( + completeGitHubSetupReaderOAuth.execute({ + principal, + input: { attempt: { ...oauthAttempt, state: 'wrong-state' }, code: 'fixture-code' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(m.oauthComplete).not.toHaveBeenCalled() + admin() + queueTableRows(user, [{ emailVerified: false }]) + await expect( + completeGitHubSetupReaderOAuth.execute({ + principal, + input: { attempt: oauthAttempt, code: 'fixture-code' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + admin() + queueTableRows(user, [{ emailVerified: true }]) + await completeGitHubSetupReaderOAuth.execute({ + principal, + input: { attempt: oauthAttempt, code: 'fixture-code' }, + }) + expect(m.oauthComplete).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + kind: 'credential_group_enrollment', + userId: 'admin', + organizationId: 'organization', + enrollmentId: 'enrollment', + }), + }) + ) + }) + it('cancellation prevents the intermediate OAuth grant as well as installation', async () => { + await startOAuth() + await cancel() + admin() + await expect( + completeGitHubSetupReaderOAuth.execute({ + principal, + input: { attempt: oauthAttempt, code: 'fixture-code' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(m.oauthComplete).not.toHaveBeenCalled() + }) + it('maps OAuth failure classifications into the authoritative setup status', async () => { + await startOAuth() + admin() + await continueGitHubSearchSetup.execute({ + principal, + input: { ...input, oauth: 'github_email_mismatch' }, + }) + await expect(status()).resolves.toMatchObject({ + status: 'failed', + error: expect.stringContaining('verified secondary email'), + }) + expect(m.connect).not.toHaveBeenCalled() + }) +}) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} + +describe('GitHub setup atomic claims', () => { + it('never wraps nested authorized operations in a global-pool transaction', async () => { + m.list.mockImplementationOnce(async () => { + expect(db.transaction).not.toHaveBeenCalled() + return { available: true, needsUserConnection: false, installations: [installation] } + }) + m.connect.mockImplementationOnce(async () => { + expect(db.transaction).not.toHaveBeenCalled() + return { credential: resultCredential } + }) + await start() + }) + it('cancellation during discovery prevents the final grant claim', async () => { + const entered = deferred() + const listed = deferred<{ + available: boolean + needsUserConnection: boolean + installations: (typeof installation)[] + }>() + m.list.mockImplementationOnce(() => { + entered.resolve() + return listed.promise + }) + const pending = start() + await entered.promise + await cancel() + listed.resolve({ available: true, needsUserConnection: false, installations: [installation] }) + await expect(pending).rejects.toMatchObject({ code: 'conflict' }) + expect(m.connect).not.toHaveBeenCalled() + await expect(status()).resolves.toEqual({ status: 'expired' }) + }) + it('a claimed final grant survives cancellation and concurrent callback replay without a duplicate grant', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + const entered = deferred() + const connected = deferred<{ credential: typeof resultCredential }>() + m.connect.mockImplementationOnce(() => { + entered.resolve() + return connected.promise + }) + const pending = callback(state) + await entered.promise + await cancel() + await callback(state) + expect(m.connect).toHaveBeenCalledTimes(1) + connected.resolve({ credential: resultCredential }) + await pending + await expect(status()).resolves.toEqual({ status: 'completed', credential: resultCredential }) + }) + it('cancellation during a reader grant prevents subsequent installation continuation', async () => { + m.list.mockResolvedValueOnce({ available: true, needsUserConnection: true, installations: [] }) + queueTableRows(credential, []) + await start() + const entered = deferred() + const authorized = deferred<{ credentialId: string }>() + m.oauthComplete.mockImplementationOnce(() => { + entered.resolve() + return authorized.promise + }) + admin() + queueTableRows(user, [{ emailVerified: true }]) + const pending = completeGitHubSetupReaderOAuth.execute({ + principal, + input: { attempt: oauthAttempt, code: 'fixture-code' }, + }) + await entered.promise + await cancel() + authorized.resolve({ credentialId: 'reader' }) + await pending + m.receipt.mockResolvedValue('reader') + await expect(resume()).rejects.toMatchObject({ code: 'validation' }) + expect(m.connect).not.toHaveBeenCalled() + await expect(status()).resolves.toEqual({ status: 'expired' }) + }) + it('an interrupted claim is not replayed and expires into a recoverable new attempt', async () => { + vi.useFakeTimers() + const state = new URL((await start()).url).searchParams.get('state')! + const attempt = (await readGitHubSetupAttempt(scope))! + await saveGitHubSetupAttempt( + { ...scope, createdAt: attempt.createdAt, phase: 'connecting', installationId: '42', state }, + 'installing' + ) + await callback(state) + await cancel() + expect(m.connect).not.toHaveBeenCalled() + vi.advanceTimersByTime(GITHUB_SETUP_TTL_MS) + await expect(status()).resolves.toEqual({ status: 'expired' }) + m.list.mockResolvedValue({ + available: true, + needsUserConnection: false, + installations: [installation], + }) + admin() + await startGitHubSearchSetup.execute({ + principal, + input: { ...input, setupId: '660e8400-e29b-41d4-a716-446655440000' }, + }) + expect(m.connect).toHaveBeenCalledTimes(1) + }) + it('fails closed when Redis refuses the transition before the grant', async () => { + const state = new URL((await start()).url).searchParams.get('state')! + m.eval.mockRejectedValueOnce(new Error('Redis unavailable')) + await expect(callback(state)).rejects.toThrow('Redis unavailable') + expect(m.connect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/github-setup.ts b/apps/sim/lib/knowledge/application/github-setup.ts new file mode 100644 index 00000000000..9fdc061c52b --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-setup.ts @@ -0,0 +1,515 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { credential, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { and, eq } from 'drizzle-orm' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { getCredentialGroupOAuthContextForEnrollment } from '@/lib/credential-groups/enrollments' +import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { readSearchConnectionCompletion } from '@/lib/credential-groups/search-connection-completion' +import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' +import { + connectGitHubSearchInstallation, + findGitHubSearchReaderCredential, + listGitHubSearchInstallations, +} from '@/lib/knowledge/application/github-installations' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { provisionKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-provisioning' +import { + expiredGitHubSetupError, + type GitHubSetupAttempt, + type GitHubSetupScope, + githubSetupScope, + issueGitHubSetupCallback, + readGitHubSetupAttempt, + resolveGitHubSetupCallback, + saveGitHubSetupAttempt, +} from '@/lib/knowledge/github-setup-state' +import { githubSetupCompletionUrl, githubSetupPageUrl } from '@/lib/knowledge/github-setup-urls' +import { + GitHubInstallationError, + getGitHubInstallationConfiguration, +} from '@/lib/oauth/github-installation' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' + +const logger = createLogger('GitHubSearchSetup') +interface SetupInput { + organizationId: string + setupId: string +} +interface SetupSelectionInput extends SetupInput { + action: { kind: 'select'; installationId: string } | { kind: 'install' } +} +interface SetupCallbackInput { + state: string + installationId?: string + setupAction?: string +} + +function safeSetupError(error: unknown): string { + if (error instanceof ManagedOAuthCredentialError) + return 'Reconnect your GitHub account, then start setup again.' + if (error instanceof GitHubInstallationError) + return error.status === 403 + ? 'Choose a GitHub account or organization you own and allow the required app permissions.' + : 'GitHub could not finish setup right now. Try again in a few minutes.' + const classified = asOrchestrationError(error) + if (classified && ['validation', 'forbidden', 'conflict'].includes(classified.code)) + return classified.message + return 'GitHub setup could not finish. Try again.' +} + +function installationUrl(state: string) { + const { installUrl } = getGitHubInstallationConfiguration() + if (!installUrl) throw new OrchestrationError('validation', 'GitHub App setup is unavailable.') + const url = new URL(installUrl) + url.searchParams.set('state', state) + return url.toString() +} + +function currentUrl(attempt: GitHubSetupAttempt): string { + switch (attempt.phase) { + case 'oauth': + return attempt.url + case 'authorizing': + return githubSetupPageUrl(attempt) + case 'installing': + return installationUrl(attempt.state) + case 'completed': + return githubSetupCompletionUrl(attempt.setupId) + case 'failed': + return githubSetupCompletionUrl(attempt.setupId, 'failed') + case 'cancelled': + throw expiredGitHubSetupError() + default: + return githubSetupPageUrl(attempt) + } +} + +async function requireAttempt(scope: GitHubSetupScope) { + const attempt = await readGitHubSetupAttempt(scope) + if (!attempt || attempt.phase === 'cancelled') throw expiredGitHubSetupError() + return attempt +} + +function base(attempt: GitHubSetupAttempt) { + return { + ...githubSetupScope(attempt), + createdAt: attempt.createdAt, + ...(attempt.intent ? { intent: attempt.intent } : {}), + } +} + +async function failAttempt( + attempt: GitHubSetupAttempt, + error: unknown, + stage: string = attempt.phase +) { + logger.warn('GitHub setup failed', { + stage, + errorType: + error instanceof TypeError + ? 'type_error' + : error instanceof SyntaxError + ? 'syntax_error' + : error instanceof Error + ? 'error' + : 'unknown', + ...(error instanceof Error && { fingerprint: sha256Hex(error.message).slice(0, 12) }), + failure: + asOrchestrationError(error)?.code ?? + (error instanceof GitHubInstallationError ? 'provider' : 'unexpected'), + ...(error instanceof GitHubInstallationError && { providerStatus: error.status }), + }) + await saveGitHubSetupAttempt( + { ...base(attempt), phase: 'failed', error: safeSetupError(error) }, + attempt.phase + ) + return { url: githubSetupCompletionUrl(attempt.setupId, 'failed') } +} + +async function connectAttempt( + principal: SessionPrincipal, + attempt: GitHubSetupAttempt, + installationId: string +) { + const connecting: GitHubSetupAttempt = { + ...base(attempt), + phase: 'connecting', + installationId, + ...('state' in attempt && attempt.state ? { state: attempt.state } : {}), + } + await saveGitHubSetupAttempt(connecting, attempt.phase) + let result + try { + result = await connectGitHubSearchInstallation.execute({ + principal, + input: { + organizationId: attempt.organizationId, + installationId, + signal: AbortSignal.timeout(60_000), + }, + }) + } catch (error) { + return failAttempt(connecting, error) + } + /** An interrupted claim expires; a fresh attempt reuses the idempotent installation binding. */ + await saveGitHubSetupAttempt( + { + ...base(attempt), + phase: 'completed', + credential: result.credential, + }, + 'connecting' + ) + return { url: githubSetupCompletionUrl(attempt.setupId) } +} + +async function installAttempt(attempt: GitHubSetupAttempt) { + const state = await issueGitHubSetupCallback(attempt) + await saveGitHubSetupAttempt({ ...base(attempt), phase: 'installing', state }, attempt.phase) + return { url: installationUrl(state) } +} + +async function discoverInstallations(principal: SessionPrincipal, attempt: GitHubSetupAttempt) { + const result = await listGitHubSearchInstallations.execute({ + principal, + input: { organizationId: attempt.organizationId, signal: AbortSignal.timeout(60_000) }, + }) + if (!result.available) + throw new OrchestrationError('validation', 'GitHub App setup is unavailable.') + if (result.needsUserConnection) return null + if (attempt.intent === 'install' || result.installations.length === 0) + return installAttempt(attempt) + if (result.installations.length === 1) + return connectAttempt(principal, attempt, result.installations[0]!.installationId) + await saveGitHubSetupAttempt( + { + ...base(attempt), + phase: 'choosing', + installations: result.installations.map( + ({ installationId, accountId, accountLogin, accountType }) => ({ + installationId, + accountId, + accountLogin, + accountType, + }) + ), + }, + attempt.phase + ) + return { url: githubSetupPageUrl(attempt) } +} + +async function authorizeReader(attempt: GitHubSetupAttempt) { + const binding = await provisionKnowledgeConnectorMembersBinding({ + organizationId: attempt.organizationId, + userId: attempt.userId, + connectorMeta: CONNECTOR_META_REGISTRY.github!, + }) + const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment({ + organizationId: attempt.organizationId, + userId: attempt.userId, + credentialGroupId: binding.credentialGroupId, + }) + const token = new URL(invitationLink).pathname.split('/').at(-1) + if (!token) throw new Error('Account enrollment did not return an invitation token') + const context = await getCredentialGroupOAuthContextForEnrollment( + { + organizationId: attempt.organizationId, + credentialGroupId: binding.credentialGroupId, + enrollmentId: enrollment.id, + email: enrollment.email, + userId: attempt.userId, + }, + binding.credentialGroupOptionId + ) + if (!context) + throw new OrchestrationError('forbidden', 'This account connection is no longer available.') + const [existing] = await db + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.organizationId, attempt.organizationId), + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, enrollment.id), + eq(credential.credentialGroupOptionId, binding.credentialGroupOptionId) + ) + ) + .limit(1) + const url = await startCredentialGroupOAuth(context, token, { + completionRedirect: true, + completionId: attempt.setupId, + returnTo: 'github-installation', + connectionIntent: existing + ? { kind: 'reconnect', credentialId: existing.id } + : { kind: 'create' }, + }) + await saveGitHubSetupAttempt({ ...base(attempt), phase: 'oauth', url }, attempt.phase) + return { url } +} + +export const startGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.startGitHubSetup, + resolveContext: ({ input }: { input: SetupInput & { intent?: 'install' } }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input }) { + const scope = { + organizationId: input.organizationId, + setupId: input.setupId, + userId: principal.userId, + sessionId: principal.sessionId, + } + const existing = await readGitHubSetupAttempt(scope) + if (existing && existing.phase !== 'starting') return { url: currentUrl(existing) } + if (await readSearchConnectionCompletion({ ...scope, completionId: input.setupId })) + throw new OrchestrationError('conflict', 'Start a new GitHub setup attempt.') + const attempt: GitHubSetupAttempt = existing ?? { + ...scope, + phase: 'starting', + createdAt: Date.now(), + ...(input.intent ? { intent: input.intent } : {}), + } + if (!existing) await saveGitHubSetupAttempt(attempt, 'new') + let stage = 'discovery' + try { + const discovered = await discoverInstallations(principal, attempt) + if (discovered) return discovered + stage = 'reader_authorization' + return await authorizeReader(attempt) + } catch (error) { + return failAttempt(attempt, error, stage) + } + }, +}) + +export const readGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readGitHubSetup, + resolveContext: ({ input }: { input: SetupInput }) => resolveKnowledgeOrganizationContext(input), + async execute({ principal, input }) { + const attempt = await readGitHubSetupAttempt({ + ...input, + userId: principal.userId, + sessionId: principal.sessionId, + }) + if (!attempt || attempt.phase === 'cancelled') return { status: 'expired' as const } + if (attempt.phase === 'completed') + return { status: 'completed' as const, credential: attempt.credential } + if (attempt.phase === 'failed') return { status: 'failed' as const, error: attempt.error } + if (attempt.phase === 'choosing') + return { status: 'choosing' as const, installations: attempt.installations } + return { status: 'pending' as const } + }, +}) + +export const cancelGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.cancelGitHubSetup, + resolveContext: ({ input }: { input: SetupInput }) => resolveKnowledgeOrganizationContext(input), + async execute({ principal, input }) { + const scope = { ...input, userId: principal.userId, sessionId: principal.sessionId } + for (let retry = 0; retry < 3; retry++) { + const attempt = await readGitHubSetupAttempt(scope) + if (attempt?.phase === 'completed' || attempt?.phase === 'connecting') break + try { + await saveGitHubSetupAttempt( + attempt + ? { ...base(attempt), phase: 'cancelled' } + : { ...scope, createdAt: Date.now(), phase: 'cancelled' }, + attempt?.phase ?? 'new' + ) + break + } catch (error) { + if (asOrchestrationError(error)?.code !== 'conflict' || retry === 2) throw error + } + } + return { success: true as const } + }, +}) + +export const continueGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.continueGitHubSetup, + resolveContext: ({ input }: { input: SetupInput & { oauth?: string } }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input }) { + const scope = { + organizationId: input.organizationId, + setupId: input.setupId, + userId: principal.userId, + sessionId: principal.sessionId, + } + const attempt = await requireAttempt(scope) + if (attempt.phase !== 'oauth' && attempt.phase !== 'authorizing') + return { url: currentUrl(attempt) } + if (isCredentialGroupOAuthFailure(input.oauth)) { + await saveGitHubSetupAttempt( + { + ...base(attempt), + phase: 'failed', + error: CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[input.oauth], + }, + attempt.phase + ) + return { url: githubSetupCompletionUrl(input.setupId, input.oauth) } + } + const receipt = await readSearchConnectionCompletion({ + ...scope, + completionId: input.setupId, + }) + const reader = await findGitHubSearchReaderCredential( + db, + input.organizationId, + principal.userId + ) + if (!receipt || reader?.id !== receipt) + throw new OrchestrationError( + 'forbidden', + 'Complete GitHub authorization before continuing setup.' + ) + try { + const result = await discoverInstallations(principal, attempt) + if (!result) + throw new OrchestrationError('forbidden', 'Your GitHub connection is no longer available.') + return result + } catch (error) { + return failAttempt(attempt, error) + } + }, +}) + +export const selectGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.selectGitHubSetup, + resolveContext: ({ input }: { input: SetupSelectionInput }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input }) { + const scope = { + organizationId: input.organizationId, + setupId: input.setupId, + userId: principal.userId, + sessionId: principal.sessionId, + } + const attempt = await requireAttempt(scope) + if (attempt.phase === 'completed') return { url: currentUrl(attempt) } + if (attempt.phase === 'connecting') return { url: currentUrl(attempt) } + if (attempt.phase !== 'choosing') + throw new OrchestrationError('conflict', 'Refresh GitHub setup before making a selection.') + if (input.action.kind === 'install') return installAttempt(attempt) + const { installationId } = input.action + if ( + !attempt.installations.some((installation) => installation.installationId === installationId) + ) + throw new OrchestrationError( + 'validation', + 'Choose one of the available GitHub installations.' + ) + return connectAttempt(principal, attempt, installationId) + }, +}) + +export const completeGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.completeGitHubSetup, + async resolveContext({ + principal, + input, + }: { + principal: SessionPrincipal + input: SetupCallbackInput + }) { + const scope = await resolveGitHubSetupCallback(input.state, principal) + return { ...(await resolveKnowledgeOrganizationContext(scope)), setupScope: scope } + }, + async execute({ principal, input, context }) { + const attempt = await requireAttempt(context.setupScope) + if (attempt.phase === 'completed' || attempt.phase === 'failed') + return { url: currentUrl(attempt) } + if ( + (attempt.phase !== 'installing' && attempt.phase !== 'connecting') || + attempt.state !== input.state + ) + throw expiredGitHubSetupError() + if (attempt.phase === 'connecting') { + if (attempt.installationId !== input.installationId) throw expiredGitHubSetupError() + return { url: currentUrl(attempt) } + } + if ( + !input.installationId || + (input.setupAction && !['install', 'update'].includes(input.setupAction)) + ) + return failAttempt( + attempt, + new OrchestrationError( + 'validation', + 'GitHub installation was not completed. Ask a GitHub organization owner to install the app, then try again.' + ) + ) + return connectAttempt(principal, attempt, input.installationId) + }, +}) + +/** An installation setup keeps its admin authority and cancellation boundary during reader OAuth. */ +export const completeGitHubSetupReaderOAuth = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.completeGitHubSetupOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: { attempt: CredentialGroupOAuthAttempt; code: string } + }) => { + const { attempt } = input + if ( + attempt.provider !== 'github-repositories' || + attempt.returnTo !== 'github-installation' || + !attempt.organizationId || + !attempt.completionId || + attempt.userId !== principal.userId + ) + throw expiredGitHubSetupError() + return resolveKnowledgeOrganizationContext({ organizationId: attempt.organizationId }) + }, + async execute({ principal, input, context, request }) { + const scope = { + organizationId: context.organizationId, + setupId: input.attempt.completionId!, + userId: principal.userId, + sessionId: principal.sessionId, + } + const setup = await requireAttempt(scope) + if ( + setup.phase !== 'oauth' || + new URL(setup.url).searchParams.get('state') !== input.attempt.state + ) + throw expiredGitHubSetupError() + const [viewer] = await db + .select({ emailVerified: user.emailVerified }) + .from(user) + .where(eq(user.id, principal.userId)) + .limit(1) + if (!viewer?.emailVerified) + throw new OrchestrationError('forbidden', 'Verify your Sim email before connecting GitHub.') + await saveGitHubSetupAttempt({ ...base(setup), phase: 'authorizing', url: setup.url }, 'oauth') + return completePublicCredentialGroupOAuth.execute({ + principal: { + kind: 'credential_group_enrollment', + userId: principal.userId, + organizationId: scope.organizationId, + credentialGroupId: input.attempt.credentialGroupId, + enrollmentId: input.attempt.enrollmentId, + email: input.attempt.email, + invitationTokenHash: sha256Hex(input.attempt.invitationToken), + }, + input, + request, + }) + }, +}) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 1dc78ba611f..0dabc3ef7fc 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -16,6 +16,13 @@ describe('knowledge operation registry', () => { it('defines unique stable semantic operation IDs', () => { const ids = Object.values(knowledgeOperations).map((operation) => operation.id) expect(ids).toEqual([ + 'knowledge.github.setup.oauth.complete', + 'knowledge.github.setup.start', + 'knowledge.github.setup.read', + 'knowledge.github.setup.cancel', + 'knowledge.github.setup.continue', + 'knowledge.github.setup.select', + 'knowledge.github.setup.complete', 'knowledge.github.installations.list', 'knowledge.github.installations.connect', 'knowledge.slack.prepare', @@ -78,10 +85,13 @@ describe('knowledge operation registry', () => { 'knowledge.search.sources.progress', 'knowledge.search.integrations.list', 'knowledge.search.integrations.overview', + 'knowledge.search.stats.read', 'knowledge.search.integrations.approve', 'knowledge.connectors.members.list', 'knowledge.connectors.members.enroll', 'knowledge.simSearch.connect', + 'knowledge.search.personalSetup.accounts.list', + 'knowledge.search.personalSetup', 'knowledge.search.sources.connectApproved', 'knowledge.search.index.read', 'knowledge.search.sources.prepare', @@ -143,11 +153,25 @@ describe('knowledge operation registry', () => { knowledgeOperations.readSearchIndex, knowledgeOperations.enrollConnectorMember, knowledgeOperations.simSearchConnect, + knowledgeOperations.listPersonalSourceSetupAccounts, + knowledgeOperations.personalSourceSetup, ]) { expect(operation.organizationOperation.minimumRole).toBe('member') } }) + it('limits personal source setup to the signed-in member without delegating credential discovery', () => { + for (const operation of [ + knowledgeOperations.listPersonalSourceSetupAccounts, + knowledgeOperations.personalSourceSetup, + ]) { + expect(operation.principalKinds).toEqual(['session']) + expect(operation.organizationOperation.principalKinds).not.toContain('organization_delegated') + expect(operation.workspaceApiKey).toBe('deny') + expect(operation.capability).toBe('knowledge.use') + } + }) + it('permits organization delegation only for Copilot reads', () => { for (const operation of Object.values(knowledgeOperations)) { if (!operation.organizationOperation.principalKinds.includes('organization_delegated')) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index e9a7e1cb9a7..09f8225cb62 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -102,6 +102,69 @@ const HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY = { } as const export const knowledgeOperations = { + completeGitHubSetupOAuth: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.oauth.complete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + startGitHubSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.start', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + readGitHubSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.read', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + cancelGitHubSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.cancel', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + continueGitHubSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.continue', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + selectGitHubSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.select', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + completeGitHubSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.setup.complete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), listGitHubInstallations: defineKnowledgeOperation( defineWorkspaceOperation({ id: 'knowledge.github.installations.list', @@ -758,6 +821,15 @@ export const knowledgeOperations = { principalKinds: ['session'], }) ), + readOrganizationSearchStats: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.search.stats.read', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), approveSearchIntegration: defineKnowledgeOperation( defineWorkspaceOperation({ id: 'knowledge.search.integrations.approve', @@ -805,6 +877,24 @@ export const knowledgeOperations = { principalKinds: ['session'], }) ), + listPersonalSourceSetupAccounts: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.search.personalSetup.accounts.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + personalSourceSetup: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.search.personalSetup', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), createApprovedSearchSource: defineKnowledgeOperation( defineWorkspaceOperation({ id: 'knowledge.search.sources.connectApproved', diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts index 043fe0f5f8d..6bb20aa3796 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts @@ -29,6 +29,31 @@ vi.mock('@/lib/sim-search/connectors', () => ({ import { organizationSearchOverviewSchema } from '@/lib/api/contracts/knowledge/connectors' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { readOrganizationSearchOverview } from '@/lib/knowledge/application/organization-search-overview' +import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits' + +/** The global drizzle mock nests fragments as params; flatten one for inspection. */ +function renderFragment(fragment: unknown): { sql: string; params: unknown[] } { + if (!fragment || typeof fragment !== 'object') return { sql: '', params: [] } + if ('conditions' in fragment && Array.isArray(fragment.conditions)) { + const parts = fragment.conditions.map(renderFragment) + return { + sql: parts.map((part) => part.sql).join(' '), + params: parts.flatMap((part) => part.params), + } + } + const rendered = (fragment as { toSQL?: () => { sql: string; params: unknown[] } }).toSQL?.() + if (!rendered) return { sql: '', params: [] } + const params: unknown[] = [] + let sqlText = rendered.sql + for (const param of rendered.params) { + if (param && typeof param === 'object' && 'toSQL' in param) { + const nested = renderFragment(param) + sqlText += ` ${nested.sql}` + params.push(...nested.params) + } else params.push(param) + } + return { sql: sqlText, params } +} const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const const input = { organizationId: 'organization' } @@ -82,6 +107,16 @@ describe('organization Search administration overview', () => { expect(result.providers[0]).toMatchObject({ status: 'needs_attention', issue }) expect(JSON.stringify(result)).not.toContain('private provider response') }) + it('does not count the per-document relisting marker as a member account error', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(knowledgeConnector, [{ ...health }]) + await readOrganizationSearchOverview.execute({ principal, input }) + const rendered = dbChainMockFns.where.mock.calls.flatMap((call) => call.map(renderFragment)) + const memberErrorClause = rendered.find((fragment) => fragment.sql.includes("'suspended'")) + expect(memberErrorClause).toBeDefined() + expect(memberErrorClause?.sql).toContain('IS NOT NULL AND ? <> ?') + expect(memberErrorClause?.params).toContain(SOURCE_CONTENT_ERROR) + }) it('keeps recovery observable while a previous error remains visible', async () => { queueTableRows(member, [{ role: 'admin' }]) queueTableRows(knowledgeConnector, [{ ...health, hasError: true, hasIndexing: true }]) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.ts b/apps/sim/lib/knowledge/application/organization-search-overview.ts index 70025fc809b..97ebe9209cc 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.ts @@ -8,14 +8,16 @@ import { knowledgeConnectorSyncLog, organizationSearchIntegration, } from '@sim/db/schema' -import { and, eq, exists, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, eq, exists, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' import { SOURCE_ACL_MAX_AGE_MS } from '@/lib/knowledge/access/freshness' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits' import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants' +import { failedDocumentCondition } from '@/lib/knowledge/documents/processing-status' import { canConnectWithDefaults, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' interface OrganizationSearchOverviewInput { @@ -100,6 +102,11 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ )) )` const cutoff = sql`statement_timestamp() - (${SOURCE_ACL_MAX_AGE_MS} * interval '1 millisecond')` + /** + * A member whose last run only had per-document content failures carries + * {@link SOURCE_CONTENT_ERROR} as a marker so its next run lists fully; the + * member itself is healthy and the documents are reported separately. + */ const hasMemberError = exists( db .select({ id: knowledgeConnectorMember.id }) @@ -110,7 +117,7 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ sql`( ${knowledgeConnectorMember.status} = 'suspended' OR (${knowledgeConnectorMember.status} = 'active' AND ( - ${knowledgeConnectorMember.lastError} IS NOT NULL + (${knowledgeConnectorMember.lastError} IS NOT NULL AND ${knowledgeConnectorMember.lastError} <> ${SOURCE_CONTENT_ERROR}) OR ${knowledgeConnectorMember.consecutiveFailures} > 0 OR coalesce(${knowledgeConnectorMember.memberSyncedThrough}, ${knowledgeConnectorMember.lastCompleteListingAt}, ${knowledgeConnectorMember.createdAt}) < ${cutoff} )) @@ -130,7 +137,7 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ ) ) ) - const hasDocumentsInState = (statuses: string[]) => + const hasDocumentsInState = (condition: SQL) => exists( db .select({ id: document.id }) @@ -143,7 +150,7 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - inArray(document.processingStatus, statuses) + condition ) ) ) @@ -188,7 +195,7 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ hasError: sql`bool_or(NOT ${paused} AND ( ${knowledgeConnector.status} = 'error' OR ${knowledgeConnector.lastSyncError} IS NOT NULL - OR ${hasDocumentsInState(['failed'])} + OR ${hasDocumentsInState(failedDocumentCondition())} OR (${knowledgeConnector.accessMode} = 'admin' AND ${latestCentralRunHasError}) OR (${knowledgeConnector.accessMode} = 'members' AND ( ${knowledgeConnector.memberSyncStatus} = 'error' @@ -197,12 +204,12 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ )) ))`, hasAccountError: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.accessMode} = 'members' AND ${hasMemberError})`, - hasDocumentError: sql`bool_or(NOT ${paused} AND ${hasDocumentsInState(['failed'])})`, + hasDocumentError: sql`bool_or(NOT ${paused} AND ${hasDocumentsInState(failedDocumentCondition())})`, hasIndexing: sql`bool_or(NOT ${paused} AND (${knowledgeConnector.accessMode} <> 'members' OR ${hasActiveMembers} OR ${knowledgeConnector.credentialId} IS NOT NULL) AND ( ${knowledgeConnector.status} IN ('pending', 'syncing') - OR ${continuing} OR ${hasDocumentsInState(['pending', 'processing'])} + OR ${continuing} OR ${hasDocumentsInState(inArray(document.processingStatus, ['pending', 'processing']))} OR (${knowledgeConnector.accessMode} = 'members' AND ( ${knowledgeConnector.memberSyncStatus} IN ('pending', 'running') OR ${hasMemberFirstListing} )) diff --git a/apps/sim/lib/knowledge/application/organization-search-stats.test.ts b/apps/sim/lib/knowledge/application/organization-search-stats.test.ts new file mode 100644 index 00000000000..7b7a2b97b30 --- /dev/null +++ b/apps/sim/lib/knowledge/application/organization-search-stats.test.ts @@ -0,0 +1,82 @@ +/** @vitest-environment node */ +import { member } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + context: vi.fn(), + policy: vi.fn(), + available: vi.fn(), + load: vi.fn(), +})) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeOwnerContext: mocks.context, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.policy, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + isOrgAdminRole: (role: string) => role === 'admin' || role === 'owner', +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireOrganizationSearchAvailable: mocks.available, +})) +vi.mock('@/lib/knowledge/search/activity-stats', () => ({ + loadOrganizationSearchStats: mocks.load, +})) + +import { readOrganizationSearchStats } from '@/lib/knowledge/application/organization-search-stats' + +const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const +const input = { organizationId: 'organization', period: '7d', surface: 'mcp' } as const + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.context.mockResolvedValue({ organizationId: 'organization' }) + mocks.policy.mockResolvedValue(null) + mocks.available.mockResolvedValue(undefined) + mocks.load.mockResolvedValue({ totals: { invocations: 3 } }) +}) + +describe('organization Search stats authorization', () => { + it.each(['admin', 'owner'])( + 'allows a current %s and forwards the selected scope', + async (role) => { + queueTableRows(member, [{ role }]) + expect(await readOrganizationSearchStats.execute({ principal, input })).toEqual({ + totals: { invocations: 3 }, + }) + expect(mocks.available).toHaveBeenCalledWith('organization') + expect(mocks.load).toHaveBeenCalledWith(input) + } + ) + it.each([ + { rows: [{ role: 'member' }], code: 'forbidden' }, + { rows: [], code: 'not_found' }, + ])('rejects unauthorized access with $code before aggregation', async ({ rows, code }) => { + queueTableRows(member, rows) + await expect(readOrganizationSearchStats.execute({ principal, input })).rejects.toMatchObject({ + code, + }) + expect(mocks.load).not.toHaveBeenCalled() + }) + it('rejects API keys before protected loading', async () => { + await expect( + readOrganizationSearchStats.execute({ + principal: { kind: 'personal_api_key', userId: 'admin', keyId: 'key' }, + input, + }) + ).rejects.toThrow() + expect(mocks.context).not.toHaveBeenCalled() + expect(mocks.load).not.toHaveBeenCalled() + }) + it('fails closed when Search is disabled', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.available.mockRejectedValueOnce(new Error('Search is disabled')) + await expect(readOrganizationSearchStats.execute({ principal, input })).rejects.toThrow( + 'Search is disabled' + ) + expect(mocks.load).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/organization-search-stats.ts b/apps/sim/lib/knowledge/application/organization-search-stats.ts new file mode 100644 index 00000000000..98aabe2973c --- /dev/null +++ b/apps/sim/lib/knowledge/application/organization-search-stats.ts @@ -0,0 +1,21 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + loadOrganizationSearchStats, + type SearchStatsInput, +} from '@/lib/knowledge/search/activity-stats' + +export const readOrganizationSearchStats = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readOrganizationSearchStats, + resolveContext: ({ input }: { input: SearchStatsInput }) => + resolveKnowledgeOwnerContext({ organizationId: input.organizationId }), + async execute({ context, input }) { + if (!context.organizationId) + throw new OrchestrationError('validation', 'Organization is required') + await requireOrganizationSearchAvailable(context.organizationId) + return loadOrganizationSearchStats({ ...input, organizationId: context.organizationId }) + }, +}) diff --git a/apps/sim/lib/knowledge/application/personal-search-account.test.ts b/apps/sim/lib/knowledge/application/personal-search-account.test.ts new file mode 100644 index 00000000000..a927fb31bfc --- /dev/null +++ b/apps/sim/lib/knowledge/application/personal-search-account.test.ts @@ -0,0 +1,133 @@ +/** @vitest-environment node */ +import { user } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + membership: vi.fn(), + approval: vi.fn(), + available: vi.fn(), + deployed: vi.fn(), + accounts: vi.fn(), +})) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + requireOrganizationMembership: mocks.membership, +})) +vi.mock('@/lib/knowledge/search/integration-policy', () => ({ + requireOrganizationSearchApproval: mocks.approval, +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireKnowledgeMemberAccessAvailable: mocks.available, +})) +vi.mock('@/lib/integrations/availability.server', () => ({ + isOAuthServiceDeploymentAvailable: mocks.deployed, +})) +vi.mock('@/lib/credentials/organization-managed', () => ({ + getOwnOrganizationManagedOAuthCredentials: mocks.accounts, +})) + +import { + authorizePersonalSearchSetup, + authorizePersonalSearchSetupCredential, +} from '@/lib/knowledge/application/personal-search-account' + +const principal = { kind: 'session', userId: 'member-1', sessionId: 'session-1' } as const +const input = { + organizationId: 'organization-1', + connectorType: 'jira', + credentialId: 'own-account', +} as const + +describe('personal Search setup authorization', () => { + beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + queueTableRows(user, [{ emailVerified: true }]) + mocks.deployed.mockReturnValue(true) + mocks.accounts.mockResolvedValue([ + { + id: 'own-account', + providerId: 'jira', + displayName: 'Work account', + scopes: ['read:jira-work'], + }, + ]) + }) + + it('permits a verified member and scopes the account lookup to that person and provider', async () => { + await expect(authorizePersonalSearchSetupCredential(principal, input)).resolves.toMatchObject({ + id: 'own-account', + }) + expect(mocks.membership).toHaveBeenCalledWith( + principal, + input.organizationId, + 'member', + 'knowledge.use' + ) + expect(mocks.approval).toHaveBeenCalledWith(input.organizationId, 'jira') + expect(mocks.accounts).toHaveBeenCalledWith({ + organizationId: input.organizationId, + userId: principal.userId, + providerId: 'jira', + credentialId: input.credentialId, + }) + }) + + it('rejects non-session callers before membership or protected account reads', async () => { + await expect( + authorizePersonalSearchSetup( + { kind: 'personal_api_key', userId: 'member-1', keyId: 'key-1' }, + input + ) + ).rejects.toThrow('Sign in') + expect(mocks.membership).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('rejects non-members before looking up credentials', async () => { + mocks.membership.mockRejectedValue(new Error('Membership ended')) + await expect(authorizePersonalSearchSetupCredential(principal, input)).rejects.toThrow( + 'Membership ended' + ) + expect(mocks.accounts).not.toHaveBeenCalled() + }) + + it('requires a verified email before approving or provisioning setup', async () => { + resetDbChainMock() + queueTableRows(user, [{ emailVerified: false }]) + await expect(authorizePersonalSearchSetup(principal, input)).rejects.toThrow( + 'Verify your email' + ) + expect(mocks.approval).not.toHaveBeenCalled() + }) + + it.each(['approval', 'available'] as const)( + 'propagates the %s refusal before account discovery', + async (gate) => { + mocks[gate].mockRejectedValue(new Error('Source disabled')) + await expect(authorizePersonalSearchSetupCredential(principal, input)).rejects.toThrow( + 'Source disabled' + ) + expect(mocks.accounts).not.toHaveBeenCalled() + } + ) + + it('rejects an unavailable OAuth deployment', async () => { + mocks.deployed.mockReturnValue(false) + await expect(authorizePersonalSearchSetupCredential(principal, input)).rejects.toThrow( + 'unavailable' + ) + expect(mocks.accounts).not.toHaveBeenCalled() + }) + + it.each([ + { accounts: [] }, + { accounts: [{ id: 'another-account', providerId: 'jira' }] }, + { accounts: [{ id: 'own-account', providerId: 'confluence' }] }, + ])('rejects revoked, foreign or mismatched grants %#', async ({ accounts }) => { + mocks.accounts.mockResolvedValue(accounts) + await expect(authorizePersonalSearchSetupCredential(principal, input)).rejects.toThrow( + 'Connect your account again' + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/personal-search-account.ts b/apps/sim/lib/knowledge/application/personal-search-account.ts new file mode 100644 index 00000000000..04a25443acb --- /dev/null +++ b/apps/sim/lib/knowledge/application/personal-search-account.ts @@ -0,0 +1,68 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { requireOrganizationMembership } from '@/lib/core/application/organization-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getOwnOrganizationManagedOAuthCredentials } from '@/lib/credentials/organization-managed' +import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' +import { requireKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' +import { requireOrganizationSearchApproval } from '@/lib/knowledge/search/integration-policy' + +export type PersonalSearchSetupConnector = 'jira' | 'confluence' + +/** Rechecks the caller's organization and approved integration before personal account discovery. */ +export async function authorizePersonalSearchSetup( + principal: Principal, + input: { organizationId: string; connectorType: PersonalSearchSetupConnector } +) { + if ( + principal.kind !== 'session' || + (input.connectorType !== 'jira' && input.connectorType !== 'confluence') + ) { + throw new OrchestrationError('forbidden', 'Sign in to connect this Search source') + } + await requireOrganizationMembership(principal, input.organizationId, 'member', 'knowledge.use') + const [viewer] = await db + .select({ emailVerified: user.emailVerified }) + .from(user) + .where(eq(user.id, principal.userId)) + .limit(1) + if (!viewer?.emailVerified) { + throw new OrchestrationError( + 'validation', + 'Verify your email address before connecting an account' + ) + } + await requireOrganizationSearchApproval(input.organizationId, input.connectorType) + await requireKnowledgeMemberAccessAvailable({ organizationId: input.organizationId }) + if (!isOAuthServiceDeploymentAvailable(input.connectorType)) { + throw new OrchestrationError('validation', 'This account connection is unavailable') + } + return principal.userId +} + +/** Personal setup can browse only a currently live grant owned by the signed-in member. */ +export async function authorizePersonalSearchSetupCredential( + principal: Principal, + input: { + organizationId: string + connectorType: PersonalSearchSetupConnector + credentialId: string + } +) { + const userId = await authorizePersonalSearchSetup(principal, input) + const accounts = await getOwnOrganizationManagedOAuthCredentials({ + organizationId: input.organizationId, + userId, + providerId: input.connectorType, + credentialId: input.credentialId, + }) + const account = accounts.find( + (entry) => entry.id === input.credentialId && entry.providerId === input.connectorType + ) + if (!account) { + throw new OrchestrationError('not_found', 'Connect your account again before choosing sources') + } + return account +} diff --git a/apps/sim/lib/knowledge/application/personal-search-integrations.ts b/apps/sim/lib/knowledge/application/personal-search-integrations.ts index 735f5acd97f..1497c88983e 100644 --- a/apps/sim/lib/knowledge/application/personal-search-integrations.ts +++ b/apps/sim/lib/knowledge/application/personal-search-integrations.ts @@ -17,6 +17,7 @@ import { listSearchSources } from '@/lib/knowledge/application/search-sources' import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' import { listOrganizationSearchApprovals } from '@/lib/knowledge/search/integration-policy' import { getConnectorAccessAvailability, SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { findSharedSlackSearchInstallation } from '@/lib/slack-search/shared-app' export interface ListPersonalSearchIntegrationsInput { organizationId: string @@ -39,7 +40,7 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ .where(eq(user.id, userId)) .limit(1) if (!viewer) throw new OrchestrationError('forbidden', 'The current person is unavailable') - const [page, overview, approvals, access] = await Promise.all([ + const [page, overview, approvals, access, sharedSlack] = await Promise.all([ listSearchSources.execute({ principal, input }), readSearchSourceOverview.execute({ principal, @@ -47,6 +48,7 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ }), listOrganizationSearchApprovals(context.organizationId), resolveKnowledgeAccessAvailability(context), + findSharedSlackSearchInstallation(context.organizationId), ]) const deployment = new Map( getIntegrationAvailability().map((entry) => [entry.type.toLowerCase(), entry]) @@ -144,7 +146,7 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ (connector) => !input.connectorId && (!input.connectorType || connector.type === input.connectorType) && - connector.type !== 'slack' && + (connector.type !== 'slack' || sharedSlack !== null) && (!configured.has(connector.type) || connector.setupFields.length > 0) && eligible(connector.type) ).map((connector) => ({ diff --git a/apps/sim/lib/knowledge/application/personal-source-setup.test.ts b/apps/sim/lib/knowledge/application/personal-source-setup.test.ts new file mode 100644 index 00000000000..9ae1a409a4d --- /dev/null +++ b/apps/sim/lib/knowledge/application/personal-source-setup.test.ts @@ -0,0 +1,480 @@ +/** @vitest-environment node */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authorizeOperation: vi.fn(), + authorize: vi.fn(), + ownAccount: vi.fn(), + listAccounts: vi.fn(), + completion: vi.fn(), + provision: vi.fn(), + enrollment: vi.fn(), + oauthContext: vi.fn(), + oauth: vi.fn(), + selector: vi.fn(), + configure: vi.fn(), + binding: vi.fn(), + group: vi.fn(), + dispatch: vi.fn(), + billing: vi.fn(), +})) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + authorizeOrganizationOperation: mocks.authorizeOperation, +})) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeOrganizationContext: async ({ organizationId }: { organizationId: string }) => ({ + organizationId, + workspaceId: undefined, + }), +})) +vi.mock('@/lib/knowledge/application/personal-search-account', () => ({ + authorizePersonalSearchSetup: mocks.authorize, + authorizePersonalSearchSetupCredential: mocks.ownAccount, +})) +vi.mock('@/lib/credentials/organization-managed', () => ({ + getOwnOrganizationManagedOAuthCredentials: mocks.listAccounts, +})) +vi.mock('@/lib/credential-groups/search-connection-completion', () => ({ + readSearchConnectionCompletion: mocks.completion, +})) +vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ + provisionKnowledgeConnectorMembersBinding: mocks.provision, +})) +vi.mock('@/lib/credential-groups/self-enrollment', () => ({ + createViewerCredentialGroupEnrollment: mocks.enrollment, +})) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + getCredentialGroupOAuthContextForEnrollment: mocks.oauthContext, +})) +vi.mock('@/lib/credential-groups/oauth', () => ({ startCredentialGroupOAuth: mocks.oauth })) +vi.mock('@/lib/selectors/application/execute-selector', () => ({ + executeSelector: { execute: mocks.selector }, +})) +vi.mock('@/lib/knowledge/application/sim-search', () => ({ + configureSimSearchConnector: { execute: mocks.configure }, +})) +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadManagedCredentialGroupBinding: mocks.binding, + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/knowledge/connectors/member-queue', () => ({ dispatchMemberSync: mocks.dispatch })) +vi.mock('@/lib/knowledge/application/billing', () => ({ + resolveKnowledgeBillingAttribution: mocks.billing, +})) +vi.mock('@/connectors/registry', () => ({ + CONNECTOR_META_REGISTRY: { jira: { name: 'Jira' }, confluence: { name: 'Confluence' } }, +})) + +import { + listPersonalSourceSetupAccounts, + personalSourceSetup, +} from '@/lib/knowledge/application/personal-source-setup' +import { MAX_SELECTOR_PAGES } from '@/lib/selectors/limits' +import type { SelectorRequest } from '@/lib/selectors/types' + +interface ValidationSelectorCall { + input: { request: SelectorRequest; signal: AbortSignal } +} + +const principal = { kind: 'session', userId: 'member-1', sessionId: 'session-1' } as const +const owner = { organizationId: 'organization-1', connectorType: 'jira' } as const +const credential = { credentialId: 'own-account', domain: 'example.atlassian.net' } +const connect = { ...owner, ...credential, action: 'connect', keys: ['PROJECT'] } as const +const account = { + id: 'own-account', + displayName: 'Work account', + providerId: 'jira', + scopes: ['read:jira-work'], +} +const runConnect = (changes = {}) => + personalSourceSetup.execute({ principal, input: { ...connect, keys: ['PROJECT'], ...changes } }) + +describe('personal source setup', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + beforeEach(() => { + vi.resetAllMocks() + mocks.authorize.mockResolvedValue(principal.userId) + mocks.ownAccount.mockResolvedValue(account) + mocks.listAccounts.mockResolvedValue([account]) + mocks.completion.mockResolvedValue(account.id) + mocks.provision.mockResolvedValue({ + credentialGroupId: 'group-1', + credentialGroupOptionId: 'option-1', + }) + mocks.enrollment.mockResolvedValue({ + enrollment: { id: 'enrollment-1', email: 'member@example.com' }, + invitationLink: 'https://example.com/credential-groups/enroll/invitation-token', + }) + mocks.oauthContext.mockResolvedValue({ option: { id: 'option-1' } }) + mocks.oauth.mockResolvedValue('https://auth.atlassian.com/authorize') + mocks.selector.mockResolvedValue({ kind: 'list', items: [{ id: 'PROJECT', label: 'Project' }] }) + mocks.configure.mockResolvedValue({ knowledgeBaseId: 'kb-1', connectorId: 'source-1' }) + mocks.binding.mockResolvedValue({ + organizationId: owner.organizationId, + credentialGroupId: 'group-1', + credentialGroupOptionId: 'option-1', + }) + mocks.group.mockResolvedValue({ credentialGroupId: 'group-1' }) + mocks.billing.mockResolvedValue({ actorUserId: principal.userId }) + mocks.dispatch.mockResolvedValue({ queued: true }) + }) + + it('lists owned accounts before a source exists and scopes completion receipts to the viewer', async () => { + const result = await listPersonalSourceSetupAccounts.execute({ + principal, + input: { ...owner, completionId: 'completion-1' }, + }) + expect(result).toEqual({ + accounts: [ + { + id: 'own-account', + name: 'Work account', + provider: 'jira', + type: 'managed_oauth', + scopes: ['read:jira-work'], + }, + ], + completedCredentialId: 'own-account', + }) + expect(mocks.completion).toHaveBeenCalledWith({ + organizationId: owner.organizationId, + userId: principal.userId, + completionId: 'completion-1', + }) + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it('does not return a receipt for a foreign or no longer active credential', async () => { + mocks.completion.mockResolvedValue('another-account') + expect( + ( + await listPersonalSourceSetupAccounts.execute({ + principal, + input: { ...owner, completionId: 'completion-1' }, + }) + ).completedCredentialId + ).toBeNull() + }) + + it('starts verified personal enrollment without creating an index or requiring project keys', async () => { + const result = await personalSourceSetup.execute({ + principal, + input: { ...owner, action: 'authorize', oauthCompletionId: 'completion-1' }, + }) + expect(result).toEqual({ kind: 'authorization', url: 'https://auth.atlassian.com/authorize' }) + expect(mocks.enrollment).toHaveBeenCalledWith({ + organizationId: owner.organizationId, + userId: principal.userId, + credentialGroupId: 'group-1', + }) + expect(mocks.oauth).toHaveBeenCalledWith({ option: { id: 'option-1' } }, 'invitation-token', { + completionRedirect: true, + returnTo: 'search', + completionId: 'completion-1', + connectionIntent: { kind: 'create' }, + }) + expect(mocks.configure).not.toHaveBeenCalled() + expect(mocks.selector).not.toHaveBeenCalled() + }) + + it('does not start OAuth if enrollment was revoked', async () => { + mocks.enrollment.mockRejectedValue(new Error('An admin removed your access')) + await expect( + personalSourceSetup.execute({ + principal, + input: { ...owner, action: 'authorize', oauthCompletionId: 'completion-1' }, + }) + ).rejects.toThrow('admin removed') + expect(mocks.oauth).not.toHaveBeenCalled() + }) + + it.each(['jira', 'confluence'] as const)( + 'dispatches %s discovery through its fixed shared selector', + async (connectorType) => { + await personalSourceSetup.execute({ + principal, + input: { + ...owner, + ...credential, + connectorType, + action: 'options', + request: { kind: 'list', cursor: 'page-2' }, + }, + }) + expect(mocks.selector).toHaveBeenCalledWith({ + principal, + request: undefined, + input: { + selectorKey: connectorType === 'jira' ? 'jira.projectKeys' : 'confluence.spaces', + scope: { kind: 'organization', organizationId: owner.organizationId }, + context: { oauthCredential: credential.credentialId, domain: credential.domain }, + personalSearchSetup: connectorType, + request: { kind: 'list', cursor: 'page-2' }, + }, + }) + } + ) + + it('reuses a prepared account, validates all selected keys, and creates the source without another OAuth attempt', async () => { + mocks.selector + .mockResolvedValueOnce({ + kind: 'list', + items: [{ id: 'PROJECT', label: 'Project' }], + nextCursor: '50', + }) + .mockResolvedValueOnce({ kind: 'list', items: [{ id: 'SECOND', label: 'Second' }] }) + await expect(runConnect({ keys: ['PROJECT', 'SECOND', 'PROJECT'] })).resolves.toEqual({ + kind: 'connected', + knowledgeBaseId: 'kb-1', + connectorId: 'source-1', + }) + expect(mocks.selector).toHaveBeenCalledTimes(2) + expect(mocks.configure).toHaveBeenCalledWith({ + principal, + request: undefined, + input: { + organizationId: owner.organizationId, + connectorType: 'jira', + memberCredentialBinding: { + credentialGroupId: 'group-1', + credentialGroupOptionId: 'option-1', + }, + sourceConfig: { domain: credential.domain, projectKey: 'PROJECT,SECOND' }, + }, + }) + expect(mocks.dispatch).toHaveBeenCalledWith('source-1', { + billingAttribution: { actorUserId: principal.userId }, + }) + expect(mocks.oauth).not.toHaveBeenCalled() + }) + + it.each([ + { kind: 'list', items: [], truncated: true, nextCursor: '50' }, + { kind: 'list', items: [] }, + ])('rejects unavailable manually entered keys before source creation %#', async (page) => { + mocks.selector.mockResolvedValueOnce(page).mockResolvedValue({ kind: 'detail', item: null }) + await expect(runConnect({ keys: ['MISSING'] })).rejects.toThrow('could not be found') + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it.each(['jira', 'confluence'] as const)( + 'verifies a manually entered %s key outside the available listing through the same authorized selector', + async (connectorType) => { + mocks.selector + .mockResolvedValueOnce({ kind: 'list', items: [] }) + .mockResolvedValueOnce({ kind: 'detail', item: { id: 'PROJECT', label: 'Project' } }) + await expect(runConnect({ connectorType })).resolves.toMatchObject({ kind: 'connected' }) + expect(mocks.selector).toHaveBeenLastCalledWith({ + principal, + request: undefined, + input: { + selectorKey: connectorType === 'jira' ? 'jira.projectKeys' : 'confluence.spaces', + scope: { kind: 'organization', organizationId: owner.organizationId }, + context: { oauthCredential: credential.credentialId, domain: credential.domain }, + personalSearchSetup: connectorType, + signal: expect.any(AbortSignal), + request: { kind: 'detail', id: 'PROJECT' }, + }, + }) + expect(mocks.ownAccount).toHaveBeenCalledTimes(2) + expect(mocks.oauth).not.toHaveBeenCalled() + } + ) + + it.each([ + { name: 'truncated results', pages: 1, truncated: true, nextCursor: '50' }, + { name: 'a repeated cursor', pages: 2, nextCursor: '50' }, + { name: 'the page limit', pages: MAX_SELECTOR_PAGES }, + ])('resolves remaining keys directly after $name', async ({ pages, truncated, nextCursor }) => { + let listed = 0 + mocks.selector.mockImplementation(({ input }: ValidationSelectorCall) => { + if (input.request.kind === 'detail') { + return { kind: 'detail', item: { id: input.request.id, label: 'Project' } } + } + listed++ + return { kind: 'list', items: [], nextCursor: nextCursor ?? String(listed), truncated } + }) + await expect(runConnect()).resolves.toMatchObject({ kind: 'connected' }) + expect(listed).toBe(pages) + expect(mocks.selector).toHaveBeenCalledTimes(pages + 1) + expect(mocks.oauth).not.toHaveBeenCalled() + }) + + it('gives direct validation a fresh deadline when listing times out', async () => { + const listing = new AbortController() + const details = new AbortController() + vi.spyOn(AbortSignal, 'timeout') + .mockReturnValueOnce(listing.signal) + .mockReturnValueOnce(details.signal) + mocks.selector + .mockImplementationOnce(({ input }: ValidationSelectorCall) => { + listing.abort(new DOMException('Listing timed out', 'TimeoutError')) + input.signal.throwIfAborted() + }) + .mockImplementationOnce(({ input }: ValidationSelectorCall) => { + expect(input.signal.aborted).toBe(false) + expect(input.request).toEqual({ kind: 'detail', id: 'PROJECT' }) + return { kind: 'detail', item: { id: 'PROJECT', label: 'Project' } } + }) + await expect(runConnect()).resolves.toMatchObject({ kind: 'connected' }) + expect(AbortSignal.timeout).toHaveBeenCalledTimes(2) + expect(mocks.oauth).not.toHaveBeenCalled() + }) + + it('fails before mutation when direct validation also exceeds its deadline', async () => { + const listing = new AbortController() + const details = new AbortController() + vi.spyOn(AbortSignal, 'timeout') + .mockReturnValueOnce(listing.signal) + .mockReturnValueOnce(details.signal) + mocks.selector + .mockResolvedValueOnce({ kind: 'list', items: [] }) + .mockImplementationOnce(({ input }: ValidationSelectorCall) => { + details.abort(new DOMException('Validation timed out', 'TimeoutError')) + input.signal.throwIfAborted() + }) + await expect(runConnect()).rejects.toThrow('took too long') + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it('does not retry direct validation after the caller cancels listing', async () => { + const caller = new AbortController() + mocks.selector.mockImplementationOnce(({ input }: ValidationSelectorCall) => { + caller.abort(new Error('Setup cancelled')) + input.signal.throwIfAborted() + }) + await expect( + personalSourceSetup.execute({ + principal, + input: { ...connect, keys: ['PROJECT'] }, + request: { headers: new Headers(), signal: caller.signal }, + }) + ).rejects.toThrow('Setup cancelled') + expect(mocks.selector).toHaveBeenCalledTimes(1) + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it('rejects a detail response for a different key', async () => { + mocks.selector + .mockResolvedValueOnce({ kind: 'list', items: [] }) + .mockResolvedValueOnce({ kind: 'detail', item: { id: 'OTHER', label: 'Other project' } }) + await expect(runConnect()).rejects.toThrow('could not be found') + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it('bounds direct validation concurrency and validates each unresolved key only once', async () => { + let release = () => {} + let allStarted = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const started = new Promise((resolve) => { + allStarted = resolve + }) + let active = 0 + let maximumActive = 0 + mocks.selector.mockImplementation(async ({ input }: ValidationSelectorCall) => { + if (input.request.kind === 'list') return { kind: 'list', items: [] } + active++ + maximumActive = Math.max(maximumActive, active) + if (active === 5) allStarted() + await gate + active-- + return { kind: 'detail', item: { id: input.request.id, label: input.request.id } } + }) + const keys = Array.from({ length: 12 }, (_, index) => `PROJECT${index}`) + const connection = runConnect({ keys: [...keys, ...keys] }) + await started + try { + expect(mocks.selector).toHaveBeenCalledTimes(6) + expect(mocks.configure).not.toHaveBeenCalled() + } finally { + release() + } + await expect(connection).resolves.toMatchObject({ kind: 'connected' }) + expect(maximumActive).toBe(5) + expect(mocks.selector).toHaveBeenCalledTimes(keys.length + 1) + }) + + it('propagates listing failures without starting direct validation', async () => { + mocks.selector.mockRejectedValueOnce(new Error('Account revoked')) + await expect(runConnect()).rejects.toThrow('Account revoked') + expect(mocks.selector).toHaveBeenCalledTimes(1) + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it('rejects stale or wrong-user credentials before provider calls', async () => { + mocks.ownAccount.mockRejectedValue(new Error('Account unavailable')) + await expect(runConnect()).rejects.toThrow('Account unavailable') + expect(mocks.selector).not.toHaveBeenCalled() + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it('rejects an account whose enrollment group differs from the organization setup', async () => { + mocks.binding.mockResolvedValue({ + organizationId: owner.organizationId, + credentialGroupId: 'other-group', + credentialGroupOptionId: 'option-1', + }) + await expect(runConnect()).rejects.toThrow('Connect your account again') + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it.each(['binding', 'ownership'] as const)( + 'stops before source creation if the caller cancels during the final %s check', + async (phase) => { + const caller = new AbortController() + if (phase === 'binding') { + mocks.binding.mockImplementationOnce(() => { + caller.abort(new Error('Setup cancelled')) + return { + organizationId: owner.organizationId, + credentialGroupId: 'group-1', + credentialGroupOptionId: 'option-1', + } + }) + } else { + mocks.ownAccount.mockResolvedValueOnce(account).mockImplementationOnce(() => { + caller.abort(new Error('Setup cancelled')) + return account + }) + } + await expect( + personalSourceSetup.execute({ + principal, + input: { ...connect, keys: ['PROJECT'] }, + request: { headers: new Headers(), signal: caller.signal }, + }) + ).rejects.toThrow('Setup cancelled') + expect(mocks.configure).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + } + ) + + it('rejects a current operation authorization denial before any setup effects', async () => { + mocks.authorizeOperation.mockRejectedValue(new Error('Membership ended')) + await expect(runConnect()).rejects.toThrow('Membership ended') + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.configure).not.toHaveBeenCalled() + }) + + it.each([ + 'example.atlassian.net/wiki/spaces', + 'user:password@example.atlassian.net', + 'example.atlassian.net?site=other', + ])('rejects an invalid site value before discovery: %s', async (domain) => { + await expect(runConnect({ domain })).rejects.toThrow('hostname') + expect(mocks.selector).not.toHaveBeenCalled() + }) + + it('keeps the configured source available if the best-effort initial dispatch fails', async () => { + mocks.dispatch.mockRejectedValue(new Error('Queue temporarily unavailable')) + await expect(runConnect()).resolves.toMatchObject({ + kind: 'connected', + connectorId: 'source-1', + }) + }) +}) diff --git a/apps/sim/lib/knowledge/application/personal-source-setup.ts b/apps/sim/lib/knowledge/application/personal-source-setup.ts new file mode 100644 index 00000000000..407ad85b918 --- /dev/null +++ b/apps/sim/lib/knowledge/application/personal-source-setup.ts @@ -0,0 +1,298 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { normalizeAtlassianSiteUrl } from '@/lib/atlassian/discovery' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { + loadManagedCredentialGroupBinding, + loadScopedAccountsCredentialListContext, +} from '@/lib/credential-groups/credentials' +import { getCredentialGroupOAuthContextForEnrollment } from '@/lib/credential-groups/enrollments' +import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' +import { readSearchConnectionCompletion } from '@/lib/credential-groups/search-connection-completion' +import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' +import { getOwnOrganizationManagedOAuthCredentials } from '@/lib/credentials/organization-managed' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeBillingAttribution } from '@/lib/knowledge/application/billing' +import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + authorizePersonalSearchSetup, + authorizePersonalSearchSetupCredential, + type PersonalSearchSetupConnector, +} from '@/lib/knowledge/application/personal-search-account' +import { configureSimSearchConnector } from '@/lib/knowledge/application/sim-search' +import { provisionKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-provisioning' +import { executeSelector } from '@/lib/selectors/application/execute-selector' +import { MAX_SELECTOR_PAGES } from '@/lib/selectors/limits' +import type { SelectorExecutionResult, SelectorRequest } from '@/lib/selectors/types' +import { MAX_PERSONAL_SOURCE_SETUP_KEYS } from '@/lib/sim-search/personal-source-setup' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' + +const logger = createLogger('PersonalSourceSetup') +const VALIDATION_PHASE_TIMEOUT_MS = 30_000 +const DETAIL_VALIDATION_CONCURRENCY = 5 + +interface PersonalSourceSetupOwner { + organizationId: string + connectorType: PersonalSearchSetupConnector +} + +type PersonalSourceSetupInput = PersonalSourceSetupOwner & + ( + | { action: 'authorize'; oauthCompletionId: string } + | { action: 'options'; credentialId: string; domain: string; request: SelectorRequest } + | { action: 'connect'; credentialId: string; domain: string; keys: string[] } + ) + +type PersonalSourceSetupResult = + | { kind: 'authorization'; url: string } + | { kind: 'connected'; knowledgeBaseId: string; connectorId: string } + | SelectorExecutionResult + +function normalizeSetupDomain(value: string) { + let url: URL + try { + url = new URL(normalizeAtlassianSiteUrl(value)) + } catch { + throw new OrchestrationError('validation', 'Enter your Atlassian site hostname') + } + if ( + url.username || + url.password || + url.port || + url.pathname !== '/' || + url.search || + url.hash || + !url.hostname.includes('.') + ) { + throw new OrchestrationError( + 'validation', + 'Enter your Atlassian site hostname without a page path' + ) + } + return url.hostname +} + +/** Lists the viewer's live accounts independently of whether any source exists yet. */ +export const listPersonalSourceSetupAccounts = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listPersonalSourceSetupAccounts, + resolveContext: ({ input }: { input: PersonalSourceSetupOwner & { completionId?: string } }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input }) { + const userId = await authorizePersonalSearchSetup(principal, input) + const accounts = await getOwnOrganizationManagedOAuthCredentials({ + organizationId: input.organizationId, + userId, + providerId: input.connectorType, + }) + const completedCredentialId = input.completionId + ? await readSearchConnectionCompletion({ + organizationId: input.organizationId, + userId, + completionId: input.completionId, + }) + : null + return { + accounts: accounts.map((account) => ({ + id: account.id, + name: account.displayName, + provider: input.connectorType, + type: 'managed_oauth' as const, + scopes: account.scopes, + })), + completedCredentialId: accounts.some((account) => account.id === completedCredentialId) + ? completedCredentialId + : null, + } + }, +}) + +/** Connects the viewer first, then configures only an approved personal-account Search source. */ +export const personalSourceSetup = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.personalSourceSetup, + resolveContext: ({ input }: { input: PersonalSourceSetupInput }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input, context, request }): Promise { + const userId = await authorizePersonalSearchSetup(principal, input) + if (input.action === 'authorize') { + const binding = await provisionKnowledgeConnectorMembersBinding({ + organizationId: input.organizationId, + connectorMeta: CONNECTOR_META_REGISTRY[input.connectorType]!, + userId, + }) + const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment({ + organizationId: input.organizationId, + userId, + credentialGroupId: binding.credentialGroupId, + }) + const token = new URL(invitationLink).pathname.split('/').at(-1) + if (!token) throw new Error('Account enrollment did not return an invitation token') + const oauth = await getCredentialGroupOAuthContextForEnrollment( + { + organizationId: input.organizationId, + credentialGroupId: binding.credentialGroupId, + enrollmentId: enrollment.id, + email: enrollment.email, + userId, + }, + binding.credentialGroupOptionId + ) + if (!oauth) + throw new OrchestrationError('forbidden', 'This account connection is no longer available') + return { + kind: 'authorization', + url: await startCredentialGroupOAuth(oauth, token, { + completionRedirect: true, + returnTo: 'search', + completionId: input.oauthCompletionId, + connectionIntent: { kind: 'create' }, + }), + } + } + + await authorizePersonalSearchSetupCredential(principal, input) + const domain = normalizeSetupDomain(input.domain) + const selectorInput = { + selectorKey: + input.connectorType === 'jira' + ? ('jira.projectKeys' as const) + : ('confluence.spaces' as const), + scope: { kind: 'organization' as const, organizationId: input.organizationId }, + context: { oauthCredential: input.credentialId, domain }, + personalSearchSetup: input.connectorType, + } + if (input.action === 'options') { + return executeSelector.execute({ + principal, + request, + input: { ...selectorInput, request: input.request }, + }) + } + if ( + !input.keys.length || + input.keys.length > MAX_PERSONAL_SOURCE_SETUP_KEYS || + input.keys.some((key) => !key.trim() || key.length > 255) + ) { + throw new OrchestrationError('validation', 'Select between 1 and 1,000 projects or spaces') + } + const keys = [...new Set(input.keys.map((key) => key.trim()))] + const remaining = new Set(keys) + const cursors = new Set() + const timeout = AbortSignal.timeout(VALIDATION_PHASE_TIMEOUT_MS) + const signal = request?.signal ? AbortSignal.any([request.signal, timeout]) : timeout + let cursor: string | undefined + for (let page = 0; page < MAX_SELECTOR_PAGES; page++) { + let result: SelectorExecutionResult + try { + signal.throwIfAborted() + result = await executeSelector.execute({ + principal, + request, + input: { + ...selectorInput, + signal, + request: { kind: 'list', ...(cursor ? { cursor } : {}) }, + }, + }) + signal.throwIfAborted() + } catch (error) { + if (timeout.aborted && !request?.signal?.aborted) break + throw error + } + if (result.kind !== 'list') throw new Error('Source discovery returned an unexpected result') + for (const option of result.items) remaining.delete(option.id) + if (remaining.size === 0) break + if (!result.nextCursor || result.truncated || cursors.has(result.nextCursor)) break + cursor = result.nextCursor + cursors.add(cursor) + } + request?.signal?.throwIfAborted() + if (remaining.size > 0) { + const detailTimeout = AbortSignal.timeout(VALIDATION_PHASE_TIMEOUT_MS) + const details = new AbortController() + const detailSignal = AbortSignal.any([ + detailTimeout, + details.signal, + ...(request?.signal ? [request.signal] : []), + ]) + try { + await mapWithConcurrency([...remaining], DETAIL_VALIDATION_CONCURRENCY, async (key) => { + detailSignal.throwIfAborted() + const result = await executeSelector.execute({ + principal, + request, + input: { + ...selectorInput, + signal: detailSignal, + request: { kind: 'detail', id: key }, + }, + }) + detailSignal.throwIfAborted() + if (result.kind !== 'detail' || result.item?.id !== key) { + throw new OrchestrationError( + 'validation', + 'Some selected projects or spaces could not be found with this account. Refresh the choices and try again.' + ) + } + }) + } catch (error) { + details.abort(error) + if (detailTimeout.aborted && !request?.signal?.aborted) { + throw new OrchestrationError( + 'validation', + 'Checking the selected projects or spaces took too long. Try fewer selections.' + ) + } + throw error + } + } + const [binding, group] = await Promise.all([ + loadManagedCredentialGroupBinding(input.credentialId), + loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId: input.organizationId, + }), + ]) + if ( + !binding || + !group || + binding.credentialGroupId !== group.credentialGroupId || + binding.organizationId !== input.organizationId + ) { + throw new OrchestrationError( + 'not_found', + 'Connect your account again before choosing sources' + ) + } + await authorizePersonalSearchSetupCredential(principal, input) + request?.signal?.throwIfAborted() + const result = await configureSimSearchConnector.execute({ + principal, + request, + input: { + organizationId: input.organizationId, + connectorType: input.connectorType, + memberCredentialBinding: { + credentialGroupId: binding.credentialGroupId, + credentialGroupOptionId: binding.credentialGroupOptionId, + }, + sourceConfig: { + domain, + [input.connectorType === 'jira' ? 'projectKey' : 'spaceKey']: [...keys].join(','), + }, + }, + }) + try { + const { dispatchMemberSync } = await import('@/lib/knowledge/connectors/member-queue') + await dispatchMemberSync(result.connectorId, { + billingAttribution: await resolveKnowledgeBillingAttribution(principal, context), + }) + } catch (error) { + logger.warn('Initial personal source sync will retry on its schedule', { + error: getErrorMessage(error), + }) + } + return { kind: 'connected', ...result } + }, +}) diff --git a/apps/sim/lib/knowledge/application/search-source-progress.ts b/apps/sim/lib/knowledge/application/search-source-progress.ts index 5543c3ec974..25e30956d0b 100644 --- a/apps/sim/lib/knowledge/application/search-source-progress.ts +++ b/apps/sim/lib/knowledge/application/search-source-progress.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { document, knowledgeBase, knowledgeConnector } from '@sim/db/schema' -import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm' +import { and, eq, exists, inArray, isNull, type SQL, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' @@ -10,6 +10,7 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { MAX_SEARCH_SOURCE_PROGRESS_ITEMS } from '@/lib/knowledge/constants' +import { failedDocumentCondition } from '@/lib/knowledge/documents/processing-status' import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integration-policy' interface ReadSearchSourceProgressInput extends ResourceOwner { @@ -34,7 +35,7 @@ export const readSearchSourceProgress = defineAuthorizedKnowledgeUseCase({ const access = await createKnowledgeAccessProvider(principal, context).getForConnectors( input.connectorIds ) - const hasDocumentsInState = (statuses: string[]) => + const hasDocumentsInState = (condition: SQL) => sql`${exists( db .select({ id: document.id }) @@ -43,7 +44,7 @@ export const readSearchSourceProgress = defineAuthorizedKnowledgeUseCase({ and( eq(document.knowledgeBaseId, knowledgeConnector.knowledgeBaseId), eq(document.connectorId, knowledgeConnector.id), - inArray(document.processingStatus, statuses), + condition, eq(document.enabled, true), eq(document.userExcluded, false), isNull(document.archivedAt), @@ -60,8 +61,10 @@ export const readSearchSourceProgress = defineAuthorizedKnowledgeUseCase({ memberSyncStatus: knowledgeConnector.memberSyncStatus, hasRetainedSyncError: sql`${knowledgeConnector.lastSyncError} IS NOT NULL`, approved: sql`${searchIntegrationAccessCondition()}`, - isIndexing: hasDocumentsInState(['pending', 'processing']), - hasIndexingError: hasDocumentsInState(['failed']), + isIndexing: hasDocumentsInState( + inArray(document.processingStatus, ['pending', 'processing']) + ), + hasIndexingError: hasDocumentsInState(failedDocumentCondition()), }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) diff --git a/apps/sim/lib/knowledge/application/search-sources.test.ts b/apps/sim/lib/knowledge/application/search-sources.test.ts index d806ccb77b5..91081980cce 100644 --- a/apps/sim/lib/knowledge/application/search-sources.test.ts +++ b/apps/sim/lib/knowledge/application/search-sources.test.ts @@ -646,7 +646,23 @@ describe('bounded Search source pagination', () => { ]) }) - it.each(['filter', 'provider', 'viewer', 'scope'] as const)( + it('excludes an account-level provider before paginating repository sources', async () => { + seed([source('drive')]) + await listSearchSources.execute({ + principal, + input: { ...input, excludeConnectorType: 'github' }, + }) + expect(dbChainMockFns.where.mock.calls).toContainEqual([ + expect.objectContaining({ + type: 'and', + conditions: expect.arrayContaining([ + { type: 'ne', left: knowledgeConnector.connectorType, right: 'github' }, + ]), + }), + ]) + }) + + it.each(['filter', 'provider', 'excluded-provider', 'viewer', 'scope'] as const)( 'rejects a cursor replayed under a different %s before reading sources', async (change) => { seed(rows(26)) @@ -666,6 +682,7 @@ describe('bounded Search source pagination', () => { cursor: first.nextCursor!, ...(change === 'filter' ? { mine: true } : {}), ...(change === 'provider' ? { connectorType: 'gmail' } : {}), + ...(change === 'excluded-provider' ? { excludeConnectorType: 'github' } : {}), }, }) ).rejects.toMatchObject({ code: 'validation' }) diff --git a/apps/sim/lib/knowledge/application/search-sources.ts b/apps/sim/lib/knowledge/application/search-sources.ts index 8f33d05cfe1..1d6129acac5 100644 --- a/apps/sim/lib/knowledge/application/search-sources.ts +++ b/apps/sim/lib/knowledge/application/search-sources.ts @@ -1,7 +1,7 @@ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { document, embedding, knowledgeBase, knowledgeConnector, user } from '@sim/db/schema' -import { and, desc, eq, exists, inArray, isNull, lt, or, sql } from 'drizzle-orm' +import { and, desc, eq, exists, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm' import { listSearchSourcesContract, searchSourceCursorSchema, @@ -22,6 +22,7 @@ import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE, SEARCH_SOURCE_PAGE_SIZE, } from '@/lib/knowledge/constants' +import { failedDocumentCondition } from '@/lib/knowledge/documents/processing-status' import { listOrganizationSearchApprovals } from '@/lib/knowledge/search/integration-policy' import { describeSearchSource } from '@/lib/sim-search/source-identity' import { getConnectorMeta } from '@/connectors/registry' @@ -30,6 +31,7 @@ export interface ListSearchSourcesInput extends ResourceOwner { cursor?: string connectorId?: string connectorType?: string + excludeConnectorType?: string search?: string mine?: boolean } @@ -43,12 +45,14 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ const userId = requirePrincipalSubjectUserId(principal) const search = input.search?.trim().toLowerCase() ?? '' const connectorType = input.connectorType?.trim() + const excludeConnectorType = input.excludeConnectorType?.trim() const cursorScope = cursorScopeKey(cursorRoute(listSearchSourcesContract), { workspaceId: context.workspaceId, organizationId: context.organizationId, userId: userId, search, connectorType: connectorType ?? '', + ...(excludeConnectorType ? { excludeConnectorType } : {}), connectorId: input.connectorId ?? '', mine: input.mine === true, order: 'newest', @@ -95,6 +99,9 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt), connectorType ? eq(knowledgeConnector.connectorType, connectorType) : undefined, + excludeConnectorType + ? ne(knowledgeConnector.connectorType, excludeConnectorType) + : undefined, input.connectorId ? eq(knowledgeConnector.id, input.connectorId) : undefined, cursor ? or( @@ -179,7 +186,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ .where(and(eq(embedding.documentId, document.id), eq(embedding.enabled, true))) )} )::int`, - failedCount: sql`count(*) FILTER (WHERE ${document.processingStatus} = 'failed')::int`, + failedCount: sql`count(*) FILTER (WHERE ${failedDocumentCondition()})::int`, isIndexing: sql`bool_or(${document.processingStatus} IN ('pending', 'processing'))`, }) .from(document) diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index abbb70f08f9..49aa6d88584 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -26,12 +26,17 @@ const mocks = vi.hoisted(() => ({ importProvenance: vi.fn(), rerank: vi.fn(), searched: vi.fn(), + recordActivity: vi.fn(), })) vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { knowledgeBaseSearched: mocks.searched }, })) +vi.mock('@/lib/knowledge/search/activity', () => ({ + recordOrganizationSearchActivity: mocks.recordActivity, +})) + vi.mock('@/lib/knowledge/reranker', () => ({ rerank: mocks.rerank, })) @@ -209,6 +214,23 @@ describe('knowledge search application use case', () => { } }) + it('meters only successful organization calls under the acting person', async () => { + await searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseIds: ['knowledge-1'], query: 'answer', topK: 10, surface: 'mcp' }, + }) + if (scope === 'organization') { + expect(mocks.recordActivity).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-canonical', + userId: 'user-1', + surface: 'mcp', + results: expect.any(Array), + }) + } else { + expect(mocks.recordActivity).not.toHaveBeenCalled() + } + }) + const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const const input = { knowledgeBaseIds: ['knowledge-1'], query: 'answer', topK: 10 } @@ -274,6 +296,7 @@ describe('knowledge search application use case', () => { input: { knowledgeBaseIds: ['knowledge-1'], query: 'answer', topK: 5 }, }) ).rejects.toThrow('Search is not enabled for this organization') + expect(mocks.recordActivity).not.toHaveBeenCalled() expect(mocks.requireOrganizationSearch).toHaveBeenCalledExactlyOnceWith('org-canonical') expect(mocks.resolveBilling).not.toHaveBeenCalled() expect(mocks.generateEmbedding).not.toHaveBeenCalled() diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 7e1d6d0da95..9568e817c9d 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -39,6 +39,7 @@ import { generateSearchEmbedding, type KbEmbeddingTarget } from '@/lib/knowledge import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { rerank } from '@/lib/knowledge/reranker' import type { RerankerStatus } from '@/lib/knowledge/reranker-models' +import { recordOrganizationSearchActivity } from '@/lib/knowledge/search/activity' import { resolveKnowledgeSearchDefaults } from '@/lib/knowledge/search/defaults' import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters' import { @@ -687,7 +688,16 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ resultSecretRegistry: registry, } }, - afterSuccess: ({ principal, context, input, result }) => { + afterSuccess: async ({ principal, context, input, result }) => { + const actorUserId = resolvePrincipalSubjectUserId(principal) + if (context.organizationId && actorUserId) { + await recordOrganizationSearchActivity({ + organizationId: context.organizationId, + userId: actorUserId, + surface: input.surface ?? 'other', + results: result.results, + }) + } PlatformEvents.knowledgeBaseSearched({ knowledgeBaseId: result.knowledgeBaseId, knowledgeBaseIds: result.knowledgeBaseIds, diff --git a/apps/sim/lib/knowledge/application/sim-search.test.ts b/apps/sim/lib/knowledge/application/sim-search.test.ts index 78e2fbf60c7..fd1b47aa7e0 100644 --- a/apps/sim/lib/knowledge/application/sim-search.test.ts +++ b/apps/sim/lib/knowledge/application/sim-search.test.ts @@ -145,6 +145,7 @@ vi.mock('@/connectors/registry', () => ({ })) import { + configureSimSearchConnector, connectSimSearchConnector, prepareSearchSource, } from '@/lib/knowledge/application/sim-search' @@ -202,6 +203,53 @@ describe('connectSimSearchConnector', () => { ) }) + it('reuses a prepared account only when the source enrollment group and option match', async () => { + mocks.resolvePermission.mockResolvedValue('read') + queueTableRows(knowledgeConnector, [ + { ...existingConnector, credentialGroupId: 'group-1', credentialGroupOptionId: 'option-1' }, + ]) + await expect( + configureSimSearchConnector.execute({ + principal, + input: { + workspaceId: 'workspace-1', + connectorType: 'google_drive', + memberCredentialBinding: { + credentialGroupId: 'group-1', + credentialGroupOptionId: 'option-1', + }, + }, + }) + ).resolves.toEqual(existingConnector) + expect(mocks.enroll).not.toHaveBeenCalled() + }) + + it.each([ + { credentialGroupId: 'other-group', credentialGroupOptionId: 'option-1' }, + { credentialGroupId: 'group-1', credentialGroupOptionId: 'other-option' }, + ])( + 'refuses an existing source with a mismatched prepared account binding %#', + async (sourceBinding) => { + mocks.resolvePermission.mockResolvedValue('read') + queueTableRows(knowledgeConnector, [{ ...existingConnector, ...sourceBinding }]) + await expect( + configureSimSearchConnector.execute({ + principal, + input: { + workspaceId: 'workspace-1', + connectorType: 'google_drive', + memberCredentialBinding: { + credentialGroupId: 'group-1', + credentialGroupOptionId: 'option-1', + }, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.enroll).not.toHaveBeenCalled() + expect(mocks.createConnector).not.toHaveBeenCalled() + } + ) + it('prepares a supported administrative source in the existing workspace index', async () => { mocks.resolvePermission.mockResolvedValue('admin') queueTableRows(knowledgeBase, [{ id: 'kb-existing' }]) diff --git a/apps/sim/lib/knowledge/application/sim-search.ts b/apps/sim/lib/knowledge/application/sim-search.ts index 92367573822..94508a94d9b 100644 --- a/apps/sim/lib/knowledge/application/sim-search.ts +++ b/apps/sim/lib/knowledge/application/sim-search.ts @@ -69,6 +69,8 @@ export interface ConnectSimSearchConnectorInput extends ResourceOwner { /** Correlates a direct provider authorization with the initiating Integrations tab. */ connectionIntent?: CredentialGroupConnectionIntent oauthCompletionId?: string + /** Internal setup assertion; a reused source must use the viewer's prepared enrollment option. */ + memberCredentialBinding?: { credentialGroupId: string; credentialGroupOptionId: string } } export interface ConnectSimSearchConnectorResult { @@ -84,6 +86,8 @@ async function findSimSearchConnector(input: ConnectSimSearchConnectorInput) { knowledgeBaseId: knowledgeBase.id, connectorId: knowledgeConnector.id, sourceConfig: knowledgeConnector.sourceConfig, + credentialGroupId: knowledgeConnector.credentialGroupId, + credentialGroupOptionId: knowledgeConnector.credentialGroupOptionId, }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) @@ -119,6 +123,17 @@ async function findSimSearchConnector(input: ConnectSimSearchConnectorInput) { ) } const match = matches[0] + if ( + match && + input.memberCredentialBinding && + (match.credentialGroupId !== input.memberCredentialBinding.credentialGroupId || + match.credentialGroupOptionId !== input.memberCredentialBinding.credentialGroupOptionId) + ) { + throw new OrchestrationError( + 'conflict', + 'This source uses a different account configuration. Ask an admin to review its settings.' + ) + } return match ? { knowledgeBaseId: match.knowledgeBaseId, connectorId: match.connectorId } : null } @@ -282,11 +297,11 @@ async function requireSimSearchSetupAdmin( * singleflight also coalesces repeated setup clicks for each source; concurrent * source creation is serialized by the connector insert transaction before enrollment. */ -export const connectSimSearchConnector = defineAuthorizedKnowledgeUseCase({ +export const configureSimSearchConnector = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.simSearchConnect, resolveContext: ({ input }: { input: ConnectSimSearchConnectorInput }) => resolveKnowledgeOwnerContext(input), - async execute({ principal, input, context, request }): Promise { + async execute({ principal, input, context, request }) { const meta = CONNECTOR_META_REGISTRY[input.connectorType] if (!meta || !canConnectPersonally(meta)) { throw new OrchestrationError( @@ -373,12 +388,23 @@ export const connectSimSearchConnector = defineAuthorizedKnowledgeUseCase({ } ) } + return target + }, +}) + +/** Creates or reuses the source, then authorizes the member when setup has not already done so. */ +export const connectSimSearchConnector = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.simSearchConnect, + resolveContext: ({ input }: { input: ConnectSimSearchConnectorInput }) => + resolveKnowledgeOwnerContext(input), + async execute({ principal, input, context, request }): Promise { + const target = await configureSimSearchConnector.execute({ principal, input, request }) const { url } = await startKnowledgeConnectorMemberEnrollment.execute({ principal, input: { knowledgeBaseId: target.knowledgeBaseId, connectorId: target.connectorId, - assertedWorkspaceId: workspaceId, + assertedWorkspaceId: context.workspaceId, assertedOrganizationId: context.organizationId, oauthCompletionId: input.oauthCompletionId, connectionIntent: input.connectionIntent, diff --git a/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts b/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts index d2f335c3e08..c57bb19758d 100644 --- a/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts @@ -258,6 +258,7 @@ describe('organization Assistant from Slack', () => { chatId: 'chat1', goRoute: '/api/mothership', autoExecuteTools: true, + searchSurface: 'slack', billingAttribution: { organizationId: 'org1', actorUserId: 'member1' }, }) ) diff --git a/apps/sim/lib/knowledge/application/slack-search/assistant.ts b/apps/sim/lib/knowledge/application/slack-search/assistant.ts index 552a68ac710..9ffc2f73142 100644 --- a/apps/sim/lib/knowledge/application/slack-search/assistant.ts +++ b/apps/sim/lib/knowledge/application/slack-search/assistant.ts @@ -54,7 +54,7 @@ import { SLACK_SEARCH_FAILED_ANSWER, SLACK_SEARCH_MAX_DURATION_SECONDS, } from '@/lib/slack-search/constants' -import type { SlackSearchJob } from '@/lib/slack-search/types' +import { type SlackSearchJob, slackSearchThreadTimestamp } from '@/lib/slack-search/types' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -204,7 +204,7 @@ export async function runSlackSearchAssistant( const responseStream = new SlackSearchAssistantStream({ token: secret.botToken, channel: job.message.channelId, - threadTs: job.message.threadTs ?? job.message.messageTs, + threadTs: slackSearchThreadTimestamp(job.message), slackUserId: job.message.userId, controller, registry: environmentContext.resolvedSecretTraceRegistry, @@ -255,6 +255,7 @@ export async function runSlackSearchAssistant( abortSignal: controller.signal, timeout: SLACK_SEARCH_MAX_DURATION_SECONDS * 1000, autoExecuteTools: true, + searchSurface: 'slack', onEvent: async (event) => { try { await responseStream.onEvent(event) diff --git a/apps/sim/lib/knowledge/application/slack-search/authorization.ts b/apps/sim/lib/knowledge/application/slack-search/authorization.ts index 0634034d4a8..37bc713ee26 100644 --- a/apps/sim/lib/knowledge/application/slack-search/authorization.ts +++ b/apps/sim/lib/knowledge/application/slack-search/authorization.ts @@ -5,6 +5,7 @@ import { findSlackSearchInstallation, loadSlackSearchCredential, } from '@/lib/knowledge/application/slack-search/repository' +import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' export function requireSlackInstallationPrincipal( principal: Principal @@ -38,6 +39,7 @@ export async function authorizeSlackSearchInstallation( throw new OrchestrationError('forbidden', 'Slack Search binding is no longer valid') } await requireOrganizationSearchAvailable(installation.organizationId) + await requireSlackSearchAppAvailable(installation.appId) const secret = await loadSlackSearchCredential( installation.credentialId, installation.organizationId diff --git a/apps/sim/lib/knowledge/application/slack-search/commands.test.ts b/apps/sim/lib/knowledge/application/slack-search/commands.test.ts new file mode 100644 index 00000000000..ec6e75df5e4 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/commands.test.ts @@ -0,0 +1,90 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ authorize: vi.fn(), receive: vi.fn() })) +vi.mock('@/lib/knowledge/application/slack-search/authorization', () => ({ + requireSlackInstallationPrincipal: (p: { kind: string }) => { + if (p.kind !== 'slack_installation') throw new Error('principal') + }, + authorizeSlackSearchInstallation: m.authorize, +})) +vi.mock('@/lib/knowledge/application/slack-search/process-message', () => ({ + receiveSlackSearchMessage: { execute: m.receive }, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://www.sim.ai' })) +vi.mock('@/lib/sim-search/connectors', () => ({ + SEARCH_CONNECTORS: [{ type: 'slack', providerId: 'slack' }], +})) + +import { receiveSlackSearchCommand } from '@/lib/knowledge/application/slack-search/commands' +import { slackSearchCommandEventId, slackSearchCommandSchema } from '@/lib/slack-search/commands' + +const input = { + api_app_id: 'A1', + team_id: 'T1', + user_id: 'U1', + channel_id: 'C1', + trigger_id: 'trigger.1', + command: '/query', + text: 'release notes', +} as const +const principal = { + kind: 'slack_installation', + appId: 'A1', + teamId: 'T1', + eventId: slackSearchCommandEventId(input), + credentialId: 'c1', + credentialVersion: 'v1', + receivedAt: new Date(), +} as const +beforeEach(() => { + vi.clearAllMocks() + m.authorize.mockResolvedValue({ installation: { organizationId: 'org' } }) + m.receive.mockResolvedValue('turn') +}) +describe('Slack commands', () => { + it('acknowledges durable intake without attempting a Slack send', async () => { + await expect(receiveSlackSearchCommand.execute({ principal, input })).resolves.toMatchObject({ + response_type: 'ephemeral', + turnId: 'turn', + }) + expect(m.receive).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ + command: '/query', + messageTs: null, + channelId: 'C1', + userId: 'U1', + query: 'release notes', + }), + }) + }) + it('uses stable deduplication for retries and rejects forged user scope', async () => { + expect(slackSearchCommandEventId({ ...input })).toBe(principal.eventId) + await expect( + receiveSlackSearchCommand.execute({ principal, input: { ...input, user_id: 'U2' } }) + ).rejects.toThrow('verified identity') + expect(m.receive).not.toHaveBeenCalled() + }) + it('returns an environment-correct personal connection link without OAuth state', async () => { + const result = await receiveSlackSearchCommand.execute({ + principal, + input: { ...input, command: '/connect', text: 'slack' }, + }) + expect(result.text).toBe( + '' + ) + expect(m.receive).not.toHaveBeenCalled() + }) + it('does not queue commands on a disabled installation', async () => { + m.authorize.mockResolvedValue(null) + await receiveSlackSearchCommand.execute({ principal, input }) + expect(m.receive).not.toHaveBeenCalled() + }) + it('does not accept unsupported commands or oversized invocations', () => { + expect(slackSearchCommandSchema.safeParse({ ...input, command: '/other' }).success).toBe(false) + expect(slackSearchCommandSchema.safeParse({ ...input, text: 'x'.repeat(40001) }).success).toBe( + false + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/commands.ts b/apps/sim/lib/knowledge/application/slack-search/commands.ts new file mode 100644 index 00000000000..3283c70ab7e --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/commands.ts @@ -0,0 +1,84 @@ +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { + authorizeSlackSearchInstallation, + requireSlackInstallationPrincipal, +} from '@/lib/knowledge/application/slack-search/authorization' +import { receiveSlackSearchMessage } from '@/lib/knowledge/application/slack-search/process-message' +import { organizationRoutes } from '@/lib/navigation/paths' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { type SlackSearchCommand, slackSearchCommandEventId } from '@/lib/slack-search/commands' + +const operation = Object.freeze({ + id: 'knowledge.slack.command', + capability: 'knowledge.use', + principalKinds: ['slack_installation'] as const, +}) + +/** Commits search intake before acknowledging; the worker creates the real private Slack thread. */ +export const receiveSlackSearchCommand: OperationUseCase< + typeof operation, + SlackSearchCommand, + { response_type: 'ephemeral'; text: string; turnId?: string } +> = { + operation, + async execute({ principal, input }) { + requireSlackInstallationPrincipal(principal) + if ( + principal.appId !== input.api_app_id || + principal.teamId !== input.team_id || + principal.eventId !== slackSearchCommandEventId(input) + ) + throw new OrchestrationError( + 'forbidden', + 'Slack command does not match its verified identity' + ) + const context = await authorizeSlackSearchInstallation(principal) + if (!context) + return { + response_type: 'ephemeral', + text: 'An admin needs to enable Sim Search for this Slack workspace.', + } + if (input.command === '/connect') { + const url = new URL( + organizationRoutes(context.installation.organizationId).integrations, + getBaseUrl() + ) + const requested = input.text.trim() + if (requested) { + const provider = SEARCH_CONNECTORS.find( + (entry) => entry.type === requested || entry.providerId === requested + ) + if (!provider) + return { + response_type: 'ephemeral', + text: 'Choose an integration in Sim to connect your account.', + } + url.searchParams.set('connectorType', provider.type) + } + return { response_type: 'ephemeral', text: `<${url.href}|Connect your sources in Sim>` } + } + const query = input.text.trim() + if (!query) return { response_type: 'ephemeral', text: 'Use /query followed by your question.' } + const turnId = await receiveSlackSearchMessage.execute({ + principal, + input: { + appId: input.api_app_id, + teamId: input.team_id, + eventId: principal.eventId, + userId: input.user_id, + channelId: input.channel_id, + command: '/query', + messageTs: null, + query: query.length > 2000 ? '' : query, + queryTooLong: query.length > 2000, + }, + }) + return { + response_type: 'ephemeral', + text: 'I’ll answer in a private Sim Search DM thread.', + turnId, + } + }, +} diff --git a/apps/sim/lib/knowledge/application/slack-search/installations.test.ts b/apps/sim/lib/knowledge/application/slack-search/installations.test.ts index 2f1a04e43ab..947f2e9c657 100644 --- a/apps/sim/lib/knowledge/application/slack-search/installations.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/installations.test.ts @@ -129,7 +129,7 @@ describe('Slack Search installation configuration', () => { id: 'install1', }) expect(mocks.credential).toHaveBeenCalledWith('cred1', 'org1') - expect(mocks.verifyBot).toHaveBeenCalledWith('secret-token', expect.any(AbortSignal)) + expect(mocks.verifyBot).toHaveBeenCalledWith('secret-token', expect.any(AbortSignal), undefined) expect(mocks.audit).toHaveBeenCalledWith( expect.objectContaining({ actorId: 'admin', diff --git a/apps/sim/lib/knowledge/application/slack-search/installations.ts b/apps/sim/lib/knowledge/application/slack-search/installations.ts index 04145d4ebc8..cd53db31d22 100644 --- a/apps/sim/lib/knowledge/application/slack-search/installations.ts +++ b/apps/sim/lib/knowledge/application/slack-search/installations.ts @@ -16,6 +16,11 @@ import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { loadSlackSearchCredential } from '@/lib/knowledge/application/slack-search/repository' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { slackBotCredentialVersion } from '@/lib/slack-search/app-configuration' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' +import { + readSharedSlackSearchApp, + requireSlackSearchAppAvailable, +} from '@/lib/slack-search/shared-app' interface OrganizationInput { organizationId: string @@ -34,12 +39,13 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ resolveKnowledgeOrganizationContext(input), async execute({ context }) { await requireOrganizationSearchAvailable(context.organizationId) - const [installations, bots] = await Promise.all([ + const [installations, bots, sharedApp] = await Promise.all([ db .select({ id: slackSearchInstallation.id, credentialId: slackSearchInstallation.credentialId, appId: slackSearchInstallation.appId, + appKind: slackApp.kind, teamId: slackSearchInstallation.teamId, teamName: slackSearchInstallation.teamName, enabled: slackSearchInstallation.enabled, @@ -48,6 +54,7 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ credentialVersion: slackSearchInstallation.credentialVersion, }) .from(slackSearchInstallation) + .leftJoin(slackApp, eq(slackApp.id, slackSearchInstallation.slackAppId)) .where(eq(slackSearchInstallation.organizationId, context.organizationId)) .limit(101), db @@ -67,6 +74,7 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ ) ) .limit(101), + readSharedSlackSearchApp(), ]) if (installations.length > 100 || bots.length > 100) throw new OrchestrationError( @@ -74,10 +82,12 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ 'Slack Search supports up to 100 bots per organization' ) return { + sharedAppAvailable: Boolean(sharedApp), installations: installations.map(({ credentialVersion, ...installation }) => { const bot = bots.find((bot) => bot.id === installation.credentialId) return { ...installation, + appKind: installation.appKind ?? 'custom', needsValidation: !bot?.encryptedKey || slackBotCredentialVersion(bot.encryptedKey, bot.appRevision ?? undefined) !== @@ -101,7 +111,11 @@ export const configureSlackSearchInstallation = defineAuthorizedKnowledgeUseCase let identity: Awaited> | undefined if (secret) { try { - identity = await verifySlackSearchBot(secret.botToken, AbortSignal.timeout(10_000)) + identity = await verifySlackSearchBot( + secret.botToken, + AbortSignal.timeout(10_000), + secret.appKind === 'shared' ? SLACK_SHARED_SEARCH_BOT_SCOPES : undefined + ) } catch (error) { if ( error instanceof SlackSearchProviderError || @@ -137,6 +151,8 @@ export const configureSlackSearchInstallation = defineAuthorizedKnowledgeUseCase const [app] = current.slackAppId ? await tx.select().from(slackApp).where(eq(slackApp.id, current.slackAppId)).limit(1) : [] + if (input.enabled && current.slackAppId) + await requireSlackSearchAppAvailable(current.slackAppId) if (current.slackAppId && !app) throw new Error('Slack app configuration is missing') if ( secret && diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts new file mode 100644 index 00000000000..296715abde3 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts @@ -0,0 +1,204 @@ +/** @vitest-environment node */ +import { credential, slackSearchInstallation, slackSearchTurn } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ app: vi.fn() })) +vi.mock('@/lib/slack-search/app-configuration', () => ({ loadSlackAppConfiguration: m.app })) + +import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle' + +const principal = { + kind: 'slack_app', + appId: 'A1', + appRevision: 'r1', + receivedAt: new Date(), +} as const +const input = { + type: 'event_callback', + api_app_id: 'A1', + team_id: 'T1', + event_id: 'Ev1', + event_time: Math.floor(Date.now() / 1000), + event: { type: 'app_uninstalled' }, +} as const +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + m.app.mockResolvedValue({ + app: { id: 'A1', kind: 'shared', revision: 'r1', organizationId: null }, + }) + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + botUserId: 'UBOT', + updatedAt: new Date(1), + }, + ]) +}) +describe('Slack access revocation', () => { + it('disables the bot and cancels queued and running work on uninstall', async () => { + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false, lastOutcome: 'app_uninstalled' }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'cancelled', outcome: 'access_revoked' }) + ) + }) + it('cancels only affected members without rotating the shared installation revision', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1', 'U2'] } } }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ managedOauthStatus: 'needs_reauth' }) + ) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + credential, + slackSearchTurn, + ]) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: slackSearchTurn.installationId, right: 'i1' }, + { type: 'inArray', column: slackSearchTurn.status, values: ['pending', 'running'] }, + { + type: 'inArray', + column: expect.objectContaining({ + strings: ['', " #>> '{message,userId}'"], + values: [slackSearchTurn.payload], + }), + values: ['U1'], + }, + ], + }) + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(2, { + type: 'and', + conditions: expect.arrayContaining([ + { type: 'eq', left: credential.organizationId, right: 'org' }, + { type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' }, + { type: 'inArray', column: credential.providerSubjectId, values: ['U1', 'U2'] }, + { + type: 'or', + conditions: [ + { type: 'isNull', column: credential.grantedAt }, + { + type: 'lte', + left: credential.grantedAt, + right: new Date(input.event_time * 1000), + }, + ], + }, + ]), + }) + }) + it('does not cancel work when no current member grants were revoked', async () => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, + }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([credential]) + }) + it.each([{ bot: ['UBOT'] }, { bot: ['UBOT'], oauth: ['U1'] }])( + 'cancels all installation work when its bot is revoked: %j', + async (tokens) => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens } }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false, revision: expect.any(String) }) + ) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: slackSearchTurn.installationId, right: 'i1' }, + { type: 'inArray', column: slackSearchTurn.status, values: ['pending', 'running'] }, + ], + }) + } + ) + it('does not invalidate the installation when a different bot token is revoked', async () => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { bot: ['OTHER'] } } }, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it.each([{ appId: 'A2' }, { receivedAt: new Date(0) }, { receivedAt: new Date(Number.NaN) }])( + 'rejects invalid verified authority %#', + async (change) => { + await expect( + revokeSlackSearchAccess.execute({ principal: { ...principal, ...change }, input }) + ).rejects.toThrow('authority') + expect(m.app).not.toHaveBeenCalled() + } + ) + it('does nothing when that app/workspace has no binding', async () => { + resetDbChainMock() + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it.each([ + { type: 'app_uninstalled' as const }, + { type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'] } }, + ])('ignores stale installation-wide revocations before any writes: %j', async (event) => { + resetDbChainMock() + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + botUserId: 'UBOT', + updatedAt: new Date(input.event_time * 1000 + 1000), + }, + ]) + await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it.each([{ oauth: ['U1'] }, { oauth: ['U1'], bot: ['UBOT'] }])( + 'revokes matching member grants independently of a newer bot installation: %j', + async (tokens) => { + resetDbChainMock() + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + botUserId: 'UBOT', + updatedAt: new Date(input.event_time * 1000 + 1000), + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens } }, + }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + credential, + slackSearchTurn, + ]) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + { + type: 'inArray', + column: expect.objectContaining({ + strings: ['', " #>> '{message,userId}'"], + values: [slackSearchTurn.payload], + }), + values: ['U1'], + }, + ]), + }) + ) + } + ) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts new file mode 100644 index 00000000000..b712a4cb6f7 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts @@ -0,0 +1,137 @@ +import { db } from '@sim/db' +import { credential, slackSearchInstallation, slackSearchTurn } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm' +import { z } from 'zod' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration' + +const id = z.string().min(1).max(200) +export const slackSearchLifecycleSchema = z.object({ + type: z.literal('event_callback'), + api_app_id: id, + team_id: id, + event_id: id, + event_time: z.number().int().positive(), + event: z.discriminatedUnion('type', [ + z.object({ type: z.literal('app_uninstalled') }), + z.object({ + type: z.literal('tokens_revoked'), + tokens: z.object({ + oauth: z.array(id).max(10000).optional(), + bot: z.array(id).max(10000).optional(), + }), + }), + ]), +}) +const operation = Object.freeze({ + id: 'knowledge.slack.revoke', + capability: 'none', + principalKinds: ['slack_app'] as const, +}) + +/** Revocations bypass the rollout gate and enabled check so disabling access never blocks cleanup. */ +export const revokeSlackSearchAccess: OperationUseCase< + typeof operation, + z.infer, + void +> = { + operation, + async execute({ principal, input }) { + if ( + principal.kind !== 'slack_app' || + principal.appId !== input.api_app_id || + !Number.isFinite(principal.receivedAt.getTime()) || + Date.now() - principal.receivedAt.getTime() > 60_000 || + principal.receivedAt.getTime() > Date.now() + ) + throw new OrchestrationError('forbidden', 'Verified Slack lifecycle authority is required') + const app = await loadSlackAppConfiguration(principal.appId) + if (!app || app.app.revision !== principal.appRevision) + throw new OrchestrationError('forbidden', 'Slack app configuration changed') + const occurredAt = new Date(input.event_time * 1000) + if (occurredAt.getTime() > Date.now() + 60_000) throw new Error('Invalid Slack revocation time') + await db.transaction(async (tx) => { + const [installation] = await tx + .select() + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.slackAppId, principal.appId), + eq(slackSearchInstallation.teamId, input.team_id) + ) + ) + .for('update') + .limit(1) + if (!installation) return + if (app.app.kind === 'custom' && app.app.organizationId !== installation.organizationId) + throw new Error('Slack installation ownership is inconsistent') + const uninstall = input.event.type === 'app_uninstalled' + const staleInstallation = installation.updatedAt > occurredAt + if (uninstall && staleInstallation) return + const revokedUsers = + input.event.type === 'tokens_revoked' ? (input.event.tokens.oauth ?? []) : [] + const revokeBot = + !staleInstallation && + (uninstall || + (input.event.type === 'tokens_revoked' && + (input.event.tokens.bot ?? []).includes(installation.botUserId))) + let revokedMemberIds: string[] = [] + if (uninstall || revokedUsers.length) { + const revokeCredentials = tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) + .where( + and( + eq(credential.organizationId, installation.organizationId), + eq(credential.type, 'managed_oauth'), + eq( + credential.authorizationAppId, + `slack:${installation.appId}:${installation.teamId}` + ), + or(isNull(credential.grantedAt), lte(credential.grantedAt, occurredAt)), + ...(uninstall ? [] : [inArray(credential.providerSubjectId, revokedUsers)]) + ) + ) + if (revokeBot) await revokeCredentials + else { + const revokedCredentials = await revokeCredentials.returning({ + providerSubjectId: credential.providerSubjectId, + }) + revokedMemberIds = revokedCredentials.flatMap(({ providerSubjectId }) => + providerSubjectId ? [providerSubjectId] : [] + ) + } + } + if (revokeBot) { + await tx + .update(slackSearchInstallation) + .set({ + revision: generateId(), + enabled: false, + lastOutcome: uninstall ? 'app_uninstalled' : 'tokens_revoked', + updatedAt: new Date(), + }) + .where(eq(slackSearchInstallation.id, installation.id)) + } else if (!revokedMemberIds.length) return + await tx + .update(slackSearchTurn) + .set({ status: 'cancelled', outcome: 'access_revoked', updatedAt: new Date() }) + .where( + and( + eq(slackSearchTurn.installationId, installation.id), + inArray(slackSearchTurn.status, ['pending', 'running']), + ...(revokeBot + ? [] + : [ + inArray( + sql`${slackSearchTurn.payload} #>> '{message,userId}'`, + revokedMemberIds + ), + ]) + ) + ) + }) + }, +} diff --git a/apps/sim/lib/knowledge/application/slack-search/mention.test.ts b/apps/sim/lib/knowledge/application/slack-search/mention.test.ts index ca430d22491..34efdaa3b0a 100644 --- a/apps/sim/lib/knowledge/application/slack-search/mention.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/mention.test.ts @@ -98,3 +98,48 @@ describe('private Slack mention roots', () => { } ) }) + +it('creates a real DM root for a slash command and never uses its trigger ID as a timestamp', async () => { + const job = slackSearchJobSchema.parse({ + installationId: 'i1', + revision: 'r1', + credentialId: 'c1', + credentialVersion: 'v1', + receivedAt: principal.receivedAt.getTime(), + message: { + appId: 'A1', + teamId: 'T1', + eventId: 'Ev1', + channelId: 'C1', + userId: 'U1', + messageTs: null, + command: '/query', + query: 'Find release notes', + queryTooLong: false, + }, + }) + queueTableRows(schemaMock.slackSearchInstallation, [ + { enabled: true, revision: 'r1', credentialVersion: 'v1' }, + ]) + queueTableRows(schemaMock.slackSearchTurn, [ + { + status: 'running', + leaseId: 'lease1', + leaseExpiresAt: new Date(Date.now() + 60000), + payload: job, + }, + ]) + const routed = await routeSlackSearchMentionToDm(principal, { + job, + turnId: 'turn1', + leaseId: 'lease1', + signal: new AbortController().signal, + }) + expect(routed.message).toMatchObject({ + channelId: 'D1', + messageTs: '1800000000.2', + threadTs: '1800000000.2', + }) + expect(mocks.post).toHaveBeenCalledOnce() + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ payload: routed })) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/mention.ts b/apps/sim/lib/knowledge/application/slack-search/mention.ts index 63b9ba9f4aa..813789d651e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/mention.ts +++ b/apps/sim/lib/knowledge/application/slack-search/mention.ts @@ -19,10 +19,10 @@ export async function routeSlackSearchMentionToDm( input: { job: SlackSearchJob; turnId: string; leaseId: string; signal: AbortSignal } ): Promise { const { job, turnId, leaseId, signal } = input - if (job.message.channelId.startsWith('D')) return job + const command = job.message.command === '/query' && job.message.messageTs === null + if (job.message.channelId.startsWith('D') && !command) return job if ( - !job.message.origin || - job.message.origin.channelId !== job.message.channelId || + (!command && (!job.message.origin || job.message.origin.channelId !== job.message.channelId)) || principal.eventId !== job.message.eventId ) throw new OrchestrationError('forbidden', 'Slack mention identity is inconsistent') @@ -100,7 +100,12 @@ export async function routeSlackSearchMentionToDm( throw new Error('Could not create the private Slack question thread') const routed = slackSearchJobSchema.parse({ ...job, - message: { ...job.message, channelId, threadTs }, + message: { + ...job.message, + channelId, + threadTs, + messageTs: job.message.messageTs ?? threadTs, + }, }) await tx .update(slackSearchTurn) diff --git a/apps/sim/lib/knowledge/application/slack-search/onboarding.ts b/apps/sim/lib/knowledge/application/slack-search/onboarding.ts index 5fd5eb22994..84602d24549 100644 --- a/apps/sim/lib/knowledge/application/slack-search/onboarding.ts +++ b/apps/sim/lib/knowledge/application/slack-search/onboarding.ts @@ -38,7 +38,11 @@ import { readSlackSearchOnboardingState, storeSlackSearchOnboardingState, } from '@/lib/slack-search/onboarding-state' -import { type SlackSearchJob, slackSearchJobSchema } from '@/lib/slack-search/types' +import { + type SlackSearchJob, + slackSearchJobSchema, + slackSearchThreadTimestamp, +} from '@/lib/slack-search/types' export const slackSearchOnboardingOperations = { /** @@ -84,7 +88,7 @@ export async function sendSlackSearchOnboarding( httpMethod: 'GET', query: { channel: job.message.channelId, - message_ts: job.message.threadTs ?? job.message.messageTs, + message_ts: slackSearchThreadTimestamp(job.message), }, signal, }) @@ -99,7 +103,7 @@ export async function sendSlackSearchOnboarding( slackUrl.password ) throw new Error('Slack returned an invalid question link') - slackUrl.searchParams.set('thread_ts', job.message.threadTs ?? job.message.messageTs) + slackUrl.searchParams.set('thread_ts', slackSearchThreadTimestamp(job.message)) slackUrl.searchParams.set('cid', job.message.channelId) const token = await storeSlackSearchOnboardingState({ turnId, @@ -146,7 +150,7 @@ export async function sendSlackSearchOnboarding( context.secret.botToken, { ...message, - thread_ts: job.message.threadTs ?? job.message.messageTs, + thread_ts: slackSearchThreadTimestamp(job.message), unfurl_links: false, unfurl_media: false, }, @@ -163,7 +167,7 @@ export async function sendSlackSearchOnboarding( context.secret.botToken, { channel: job.message.channelId, - thread_ts: job.message.threadTs ?? job.message.messageTs, + thread_ts: slackSearchThreadTimestamp(job.message), text: 'I don’t have any sources I can search for you yet. Check the “Connect sources” message in our DM to get set up, then retry this question.', unfurl_links: false, unfurl_media: false, @@ -262,7 +266,7 @@ async function resolveOnboarding(principal: Principal, token: string) { const conversationKey = slackSearchConversationKey( job.installationId, job.message.channelId, - job.message.threadTs ?? job.message.messageTs + slackSearchThreadTimestamp(job.message) ) if (turn.conversationKey !== conversationKey) throw new OrchestrationError('forbidden', 'The Slack conversation binding changed') @@ -281,7 +285,7 @@ async function resolveOnboarding(principal: Principal, token: string) { binding.installationId !== job.installationId || binding.slackUserId !== job.message.userId || binding.channelId !== job.message.channelId || - binding.threadTs !== (job.message.threadTs ?? job.message.messageTs) + binding.threadTs !== slackSearchThreadTimestamp(job.message) ) throw new OrchestrationError('forbidden', 'The Slack thread belongs to a different account') } @@ -339,7 +343,7 @@ export const retrySlackSearchOnboarding: OperationUseCase< message: { ...resolved.job.message, eventId: resolved.retryEventId, - threadTs: resolved.job.message.threadTs ?? resolved.job.message.messageTs, + threadTs: slackSearchThreadTimestamp(resolved.job.message), messageTs: `${Math.floor(now / 1000)}.${String((now % 1000) * 1000).padStart(6, '0')}`, }, }, diff --git a/apps/sim/lib/knowledge/application/slack-search/process-message.ts b/apps/sim/lib/knowledge/application/slack-search/process-message.ts index 3483d35a0f2..e5b38ee3457 100644 --- a/apps/sim/lib/knowledge/application/slack-search/process-message.ts +++ b/apps/sim/lib/knowledge/application/slack-search/process-message.ts @@ -16,6 +16,7 @@ import { import { SLACK_SEARCH_QUERY_TOO_LONG } from '@/lib/slack-search/constants' import { slackSearchReply } from '@/lib/slack-search/messages' import type { SlackSearchJob, SlackSearchMessage } from '@/lib/slack-search/types' +import { slackSearchThreadTimestamp } from '@/lib/slack-search/types' const receiveOperation = Object.freeze({ id: 'knowledge.slack.receive', @@ -43,7 +44,7 @@ function requireMessageBinding(principal: SlackInstallationPrincipal, message: S export const receiveSlackSearchMessage: OperationUseCase< typeof receiveOperation, SlackSearchMessage, - void + string | undefined > = { operation: receiveOperation, async execute({ principal, input }) { @@ -64,7 +65,8 @@ export const receiveSlackSearchMessage: OperationUseCase< receivedAt: principal.receivedAt.getTime(), message: { ...input, query }, }) - await dispatchSlackSearchTurn(turnId) + if (!input.command) await dispatchSlackSearchTurn(turnId) + return turnId }, } @@ -103,7 +105,7 @@ export const respondToSlackSearchMessage: OperationUseCase< const response = await postSlackMessage( context.secret.botToken, slackSearchReply( - { ...job.message, threadTs: job.message.threadTs ?? job.message.messageTs }, + { ...job.message, threadTs: slackSearchThreadTimestamp(job.message) }, SLACK_SEARCH_QUERY_TOO_LONG ), AbortSignal.any([input.controller.signal, AbortSignal.timeout(10_000)]) diff --git a/apps/sim/lib/knowledge/application/slack-search/repository.ts b/apps/sim/lib/knowledge/application/slack-search/repository.ts index 82038fb9e71..0162dd8defc 100644 --- a/apps/sim/lib/knowledge/application/slack-search/repository.ts +++ b/apps/sim/lib/knowledge/application/slack-search/repository.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, slackSearchInstallation } from '@sim/db/schema' +import { credential, slackApp, slackSearchInstallation } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getSlackBotCredential } from '@/lib/oauth/credential-service' @@ -9,8 +9,12 @@ export type SlackSearchInstallation = typeof slackSearchInstallation.$inferSelec export async function loadSlackSearchCredential(credentialId: string, organizationId: string) { const [row] = await db - .select() + .select({ + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + appKind: slackApp.kind, + }) .from(credential) + .leftJoin(slackApp, eq(slackApp.id, credential.slackAppId)) .where( and( eq(credential.id, credentialId), @@ -25,7 +29,7 @@ export async function loadSlackSearchCredential(credentialId: string, organizati const secret = await getSlackBotCredential(credentialId) if (!secret?.signingSecret) throw new OrchestrationError('validation', 'Reconnect this bot using Slack Search setup') - return { ...secret, version: secret.credentialVersion } + return { ...secret, appKind: row.appKind, version: secret.credentialVersion } } export async function findSlackSearchInstallation(credentialId: string) { diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts index 2fea4eba5b1..2f197b744b9 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts @@ -13,7 +13,12 @@ const m = vi.hoisted(() => ({ set: vi.fn(), audit: vi.fn(), baseUrl: vi.fn(), + shared: vi.fn(), + revoke: vi.fn(), + validateGrant: vi.fn(), + ensureGroup: vi.fn(), })) +vi.mock('@/lib/slack-search/shared-app', () => ({ readSharedSlackSearchApp: m.shared })) vi.mock('@sim/audit', () => ({ AuditAction: { ORGANIZATION_UPDATED: 'organization.updated' }, AuditResourceType: { ORGANIZATION: 'organization' }, @@ -43,7 +48,12 @@ vi.mock('@/lib/slack-search/oauth-state', () => ({ consumeSlackSearchOAuthAttempt: m.consume, storeSlackSearchOAuthAttempt: m.store, })) -vi.mock('@/lib/internal/slack/oauth', () => ({ exchangeSlackBotAuthorization: m.exchange })) +vi.mock('@/lib/internal/slack/oauth', () => ({ + exchangeSlackBotAuthorization: m.exchange, + revokeSlackBotAuthorization: m.revoke, + validateSlackBotAuthorization: m.validateGrant, +})) +vi.mock('@/lib/credential-groups/service', () => ({ ensureWorkspaceAccountsGroup: m.ensureGroup })) vi.mock('@/lib/credential-groups/organization-slack-app', () => ({ loadOrganizationSlackMemberApps: async () => [], adoptOrganizationSlackMemberApp: vi.fn(), @@ -84,6 +94,10 @@ const complete = () => completeSlackSearchSetup.execute({ principal, input: { state: 'state', code: 'code' } }) beforeEach(() => { vi.clearAllMocks() + m.shared.mockResolvedValue(null) + m.revoke.mockResolvedValue(undefined) + m.validateGrant.mockReset() + m.ensureGroup.mockResolvedValue({ id: 'accounts' }) m.baseUrl.mockReturnValue('https://sim.test') m.membership.mockResolvedValue([{ role: 'admin' }]) m.rows.mockReset().mockResolvedValue([]) @@ -274,3 +288,139 @@ describe('Search OAuth installation', () => { expect(new URL(result.authorizationUrl).searchParams.has('user_scope')).toBe(false) }) }) + +it('rejects a shared-app callback if the global configuration was disabled or rotated', async () => { + m.consume.mockResolvedValue({ ...attempt, sharedApp: { id: 'ASHARED', revision: 'app-rev' } }) + await expect(complete()).rejects.toThrow('configuration changed') + expect(m.exchange).not.toHaveBeenCalled() + m.shared.mockResolvedValue({ id: 'ASHARED', revision: 'new-rev' }) + await expect(complete()).rejects.toThrow() + expect(m.exchange).not.toHaveBeenCalled() +}) + +describe('shared app completion', () => { + const sharedApp = { id: 'A1', revision: 'shared-revision', kind: 'shared', organizationId: null } + beforeEach(() => { + m.shared.mockResolvedValue(sharedApp) + m.consume.mockResolvedValue({ + ...attempt, + sharedApp: { id: sharedApp.id, revision: sharedApp.revision }, + }) + }) + + it('commits the personal app configuration, bot credential and installation in one transaction', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'accounts', options: [], encryptedProviderConfiguration: null }, + ]) + await expect(complete()).resolves.toEqual({ organizationId: 'org1' }) + expect(db.transaction).toHaveBeenCalledOnce() + expect(m.ensureGroup).toHaveBeenCalledWith( + { kind: 'organization', organizationId: 'org1' }, + 'admin', + undefined, + expect.objectContaining({ insert: expect.any(Function) }) + ) + const group = m.set.mock.calls[0][0] + expect(group.options).toEqual([ + expect.objectContaining({ + provider: 'slack', + authorizationAppId: 'slack:A1:T1', + status: 'active', + requiredScopes: expect.arrayContaining([ + 'channels:history', + 'groups:history', + 'im:history', + 'mpim:history', + 'users:read.email', + ]), + }), + ]) + const configuration = JSON.parse( + group.encryptedProviderConfiguration.slice('encrypted:'.length) + ) + expect(configuration.slack).toMatchObject({ + source: 'slack_app', + appId: 'A1', + teamId: 'T1', + scopes: group.options[0].requiredScopes, + }) + expect(configuration.slack).not.toHaveProperty('clientSecret') + const rows = m.values.mock.calls.map(([value]) => value) + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ + organizationId: 'org1', + workspaceId: null, + type: 'service_account', + slackAppId: 'A1', + }) + expect(rows[1]).toMatchObject({ + organizationId: 'org1', + credentialId: rows[0].id, + slackAppId: 'A1', + appId: 'A1', + teamId: 'T1', + enabled: true, + }) + expect(m.verify).toHaveBeenCalledTimes(2) + expect(m.revoke).not.toHaveBeenCalled() + expect(m.audit).toHaveBeenCalledOnce() + }) + + it('revokes an unused shared bot grant after a conflicting workspace binding', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'other-app' }]) + await expect(complete()).rejects.toThrow('already has an active Search installation') + expect(m.revoke).toHaveBeenCalledWith('bot-token') + expect(m.values).not.toHaveBeenCalled() + expect(m.audit).not.toHaveBeenCalled() + }) + + it('revokes an unused shared grant after a database write fails', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'accounts', options: [], encryptedProviderConfiguration: null }, + ]) + m.values.mockImplementationOnce(() => { + throw new Error('write failed') + }) + await expect(complete()).rejects.toThrow('write failed') + expect(m.revoke).toHaveBeenCalledWith('bot-token') + expect(m.audit).not.toHaveBeenCalled() + }) + + it('never revokes a bot with an existing installation when the initiating admin loses access', async () => { + m.verify.mockImplementationOnce(async () => { + m.membership.mockResolvedValue([{ role: 'member' }]) + return identity + }) + m.rows.mockResolvedValueOnce([{ id: 'existing-installation' }]) + await expect(complete()).rejects.toThrow('administrator') + expect(m.revoke).not.toHaveBeenCalled() + expect(m.values).not.toHaveBeenCalled() + }) + + it('revokes a shared grant rejected by scope or token-rotation policy', async () => { + m.validateGrant.mockImplementationOnce(() => { + throw new Error('unsupported grant') + }) + await expect(complete()).rejects.toThrow('unsupported grant') + expect(m.revoke).toHaveBeenCalledWith('bot-token') + expect(m.values).not.toHaveBeenCalled() + }) + + it('surfaces cleanup failure with a concrete recovery step', async () => { + m.verify.mockRejectedValueOnce(new Error('invalid bot')) + m.revoke.mockRejectedValueOnce(new Error('provider failed')) + await expect(complete()).rejects.toThrow('Remove the unused app in Slack before retrying') + expect(m.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index dafec925b36..4a6c46d2da5 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -11,7 +11,12 @@ import { adoptOrganizationSlackMemberApp, loadOrganizationSlackMemberApps, } from '@/lib/credential-groups/organization-slack-app' -import { exchangeSlackBotAuthorization } from '@/lib/internal/slack/oauth' +import { configureSharedSlackMemberApp } from '@/lib/credential-groups/shared-slack-app' +import { + exchangeSlackBotAuthorization, + revokeSlackBotAuthorization, + validateSlackBotAuthorization, +} from '@/lib/internal/slack/oauth' import { SlackSearchConfigurationError, SlackSearchProviderError, @@ -23,12 +28,13 @@ import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' import { slackBotCredentialVersion } from '@/lib/slack-search/app-configuration' -import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' +import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' import { createSlackSearchManifest, SLACK_SEARCH_CALLBACK_PATH } from '@/lib/slack-search/manifest' import { consumeSlackSearchOAuthAttempt, storeSlackSearchOAuthAttempt, } from '@/lib/slack-search/oauth-state' +import { readSharedSlackSearchApp } from '@/lib/slack-search/shared-app' interface PrepareInput { organizationId: string @@ -36,6 +42,7 @@ interface PrepareInput { description: string } interface StartInput extends PrepareInput { + mode?: 'custom' | 'shared' installationId?: string clientId?: string clientSecret?: string @@ -83,7 +90,9 @@ export const prepareSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ getBaseUrl(), member.scopes ) + const sharedApp = await readSharedSlackSearchApp() return { + sharedAppId: sharedApp?.id ?? null, manifest: JSON.stringify(manifest, null, 2), existingApp: member.app, createAppUrl: `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(JSON.stringify(manifest))}`, @@ -114,19 +123,32 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ : [] if (input.installationId && !installation) throw new OrchestrationError('not_found', 'Slack Search installation not found') - const [app] = installation?.slackAppId + const [savedApp] = installation?.slackAppId ? await db .select() .from(slackApp) - .where( - and( - eq(slackApp.id, installation.slackAppId), - eq(slackApp.organizationId, context.organizationId), - eq(slackApp.kind, 'custom') - ) - ) + .where(and(eq(slackApp.id, installation.slackAppId))) .limit(1) : [] + const shared = input.mode === 'shared' + if (savedApp && (savedApp.kind === 'shared') !== shared) + throw new OrchestrationError( + 'conflict', + 'Remove the existing installation before switching Slack apps' + ) + if (savedApp?.kind === 'custom' && savedApp.organizationId !== context.organizationId) + throw new OrchestrationError('forbidden', 'Slack app ownership changed') + const app = shared ? await readSharedSlackSearchApp() : savedApp + if (shared && (!app || input.clientId || input.clientSecret || input.signingSecret)) + throw new OrchestrationError( + 'validation', + 'Shared Slack app setup is unavailable or contains custom credentials' + ) + if (shared && member.app && member.app.appId !== app?.id) + throw new OrchestrationError( + 'conflict', + 'Remove the previous Slack source configuration before switching apps; members must reconnect' + ) const clientId = input.clientId ?? app?.clientId const encryptedClientSecret = input.clientSecret ? (await encryptSecret(input.clientSecret)).encrypted @@ -141,6 +163,7 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ ) const redirectUri = new URL(SLACK_SEARCH_CALLBACK_PATH, origin).href const state = await storeSlackSearchOAuthAttempt({ + ...(shared && app ? { sharedApp: { id: app.id, revision: app.revision } } : {}), userId: principal.userId, sessionId: principal.sessionId, organizationId: context.organizationId, @@ -168,7 +191,7 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ const url = new URL('https://slack.com/oauth/v2/authorize') url.search = new URLSearchParams({ client_id: clientId, - scope: SLACK_SEARCH_SCOPES.join(','), + scope: (shared ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES).join(','), redirect_uri: redirectUri, state, ...(installation ? { team: installation.teamId } : {}), @@ -177,6 +200,39 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ }, }) +/** Cleanup shares installation locks; an existing bot must never be revoked by a failed setup. */ +async function revokeUninstalledSharedGrant( + grant: Awaited> +) { + try { + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${grant.team.id}`}, 0))` + ) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${grant.app_id}`}, 0))` + ) + const [installation] = await tx + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.appId, grant.app_id), + eq(slackSearchInstallation.teamId, grant.team.id) + ) + ) + .limit(1) + if (installation) return + await revokeSlackBotAuthorization(grant.access_token) + }) + } catch { + throw new OrchestrationError( + 'validation', + 'Slack setup failed and token cleanup could not be verified. Remove the unused app in Slack before retrying.' + ) + } +} + export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.completeSlackInstallation, async resolveContext({ @@ -200,6 +256,14 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ ) await requireOrganizationSearchAvailable(context.organizationId) const { attempt } = context + if (attempt.sharedApp) { + const app = await readSharedSlackSearchApp() + if (app?.id !== attempt.sharedApp.id || app.revision !== attempt.sharedApp.revision) + throw new OrchestrationError( + 'conflict', + 'Shared Slack app configuration changed. Start again.' + ) + } const { decrypted: clientSecret } = await decryptSecret(attempt.encryptedClientSecret) const grant = await exchangeSlackBotAuthorization({ clientId: attempt.clientId, @@ -207,210 +271,258 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ code: input.code, redirectUri: attempt.redirectUri, }) - let identity: Awaited> try { - identity = await verifySlackSearchBot(grant.access_token, AbortSignal.timeout(10_000)) - } catch (error) { - if ( - error instanceof SlackSearchConfigurationError || - error instanceof SlackSearchProviderError - ) - throw new OrchestrationError('validation', error.message) - throw error - } - if ( - identity.appId !== grant.app_id || - identity.teamId !== grant.team.id || - identity.botUserId !== grant.bot_user_id - ) - throw new OrchestrationError( - 'validation', - 'Slack returned an inconsistent installation identity' - ) - if ( - attempt.installation && - (attempt.installation.appId !== identity.appId || - attempt.installation.teamId !== identity.teamId) - ) - throw new OrchestrationError('conflict', 'Reconnect the same Slack app and workspace') - if ( - attempt.memberApp && - (attempt.memberApp.appId !== identity.appId || attempt.memberApp.teamId !== identity.teamId) - ) - throw new OrchestrationError( - 'conflict', - 'Install the same Slack app and workspace used for member indexing' - ) - const { encrypted: encryptedToken } = await encryptSecret( - JSON.stringify({ - type: SLACK_CUSTOM_BOT_SECRET_TYPE, - botToken: grant.access_token, - teamId: identity.teamId, - botUserId: identity.botUserId, - teamName: identity.teamName, - }) - ) - await authorizeOrganizationOperation( - principal, - knowledgeOperations.completeSlackInstallation.organizationOperation, - context - ) - await db.transaction(async (tx) => { - /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${identity.teamId}`}, 0))` - ) - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${identity.appId}`}, 0))` - ) - const [existingApp] = await tx - .select() - .from(slackApp) - .where(eq(slackApp.id, identity.appId)) - .for('update') - .limit(1) - if ( - existingApp && - (existingApp.kind !== 'custom' || existingApp.organizationId !== context.organizationId) + validateSlackBotAuthorization( + grant, + attempt.sharedApp ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES ) - throw new OrchestrationError( - 'conflict', - 'This Slack app belongs to another installation owner' + let identity: Awaited> + try { + identity = await verifySlackSearchBot( + grant.access_token, + AbortSignal.timeout(10_000), + attempt.sharedApp ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES ) + } catch (error) { + if ( + error instanceof SlackSearchConfigurationError || + error instanceof SlackSearchProviderError + ) + throw new OrchestrationError('validation', error.message) + throw error + } if ( - attempt.installation?.appRevision && - existingApp?.revision !== attempt.installation.appRevision + (attempt.sharedApp && identity.appId !== attempt.sharedApp.id) || + identity.appId !== grant.app_id || + identity.teamId !== grant.team.id || + identity.botUserId !== grant.bot_user_id ) throw new OrchestrationError( - 'conflict', - 'The Slack app credentials changed during setup. Start again.' - ) - const [existing] = await tx - .select() - .from(slackSearchInstallation) - .where( - attempt.installation - ? eq(slackSearchInstallation.id, attempt.installation.id) - : and( - eq(slackSearchInstallation.appId, identity.appId), - eq(slackSearchInstallation.teamId, identity.teamId) - ) + 'validation', + 'Slack returned an inconsistent installation identity' ) - .for('update') - .limit(1) if ( attempt.installation && - (!existing || - existing.revision !== attempt.installation.revision || - existing.credentialId !== attempt.installation.credentialId) + (attempt.installation.appId !== identity.appId || + attempt.installation.teamId !== identity.teamId) + ) + throw new OrchestrationError('conflict', 'Reconnect the same Slack app and workspace') + if ( + attempt.memberApp && + (attempt.memberApp.appId !== identity.appId || attempt.memberApp.teamId !== identity.teamId) ) throw new OrchestrationError( 'conflict', - 'This installation changed during setup. Start setup again.' + 'Install the same Slack app and workspace used for member indexing' ) - if (existing && (!attempt.installation || existing.organizationId !== context.organizationId)) - throw new OrchestrationError( - 'conflict', - 'This app is already connected. Use Reconnect on its existing installation.' + const { encrypted: encryptedToken } = await encryptSecret( + JSON.stringify({ + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + botToken: grant.access_token, + teamId: identity.teamId, + botUserId: identity.botUserId, + teamName: identity.teamName, + }) + ) + await authorizeOrganizationOperation( + principal, + knowledgeOperations.completeSlackInstallation.organizationOperation, + context + ) + await db.transaction(async (tx) => { + /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${identity.teamId}`}, 0))` ) - const [active] = await tx - .select({ id: slackSearchInstallation.id }) - .from(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.teamId, identity.teamId), - eq(slackSearchInstallation.enabled, true), - existing ? ne(slackSearchInstallation.id, existing.id) : undefined + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${identity.appId}`}, 0))` + ) + const [existingApp] = await tx + .select() + .from(slackApp) + .where(eq(slackApp.id, identity.appId)) + .for('update') + .limit(1) + if ( + attempt.sharedApp + ? !existingApp || + existingApp.kind !== 'shared' || + existingApp.organizationId !== null || + existingApp.revision !== attempt.sharedApp.revision + : existingApp && + (existingApp.kind !== 'custom' || + existingApp.organizationId !== context.organizationId) + ) + throw new OrchestrationError( + 'conflict', + 'This Slack app belongs to another installation owner' ) + if ( + attempt.installation?.appRevision && + existingApp?.revision !== attempt.installation.appRevision ) - .limit(1) - if (active) - throw new OrchestrationError( - 'conflict', - 'This Slack workspace already has an active Search installation' + throw new OrchestrationError( + 'conflict', + 'The Slack app credentials changed during setup. Start again.' + ) + const [existing] = await tx + .select() + .from(slackSearchInstallation) + .where( + attempt.installation + ? eq(slackSearchInstallation.id, attempt.installation.id) + : and( + eq(slackSearchInstallation.appId, identity.appId), + eq(slackSearchInstallation.teamId, identity.teamId) + ) + ) + .for('update') + .limit(1) + if ( + attempt.installation && + (!existing || + existing.revision !== attempt.installation.revision || + existing.credentialId !== attempt.installation.credentialId) ) - const appRevision = generateId() - const appValues = { - id: identity.appId, - kind: 'custom' as const, - organizationId: context.organizationId, - clientId: attempt.clientId, - encryptedClientSecret: attempt.encryptedClientSecret, - encryptedSigningSecret: attempt.encryptedSigningSecret, - revision: appRevision, - updatedAt: new Date(), - } - await tx - .insert(slackApp) - .values(appValues) - .onConflictDoUpdate({ target: slackApp.id, set: appValues }) - await adoptOrganizationSlackMemberApp( - tx, - context.organizationId, - identity.appId, - identity.teamId, - attempt.clientId - ) - const credentialId = existing?.credentialId ?? generateId() - const credentialValues = { - slackAppId: identity.appId, - displayName: attempt.name, - description: attempt.description, - encryptedServiceAccountKey: encryptedToken, - updatedAt: new Date(), - } - if (existing) { - const [updated] = await tx - .update(credential) - .set(credentialValues) + throw new OrchestrationError( + 'conflict', + 'This installation changed during setup. Start setup again.' + ) + if ( + existing && + (!attempt.installation || existing.organizationId !== context.organizationId) + ) + throw new OrchestrationError( + 'conflict', + 'This app is already connected. Use Reconnect on its existing installation.' + ) + const [active] = await tx + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) .where( and( - eq(credential.id, credentialId), - eq(credential.organizationId, context.organizationId), - eq(credential.type, 'service_account'), - eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + eq(slackSearchInstallation.teamId, identity.teamId), + eq(slackSearchInstallation.enabled, true), + existing ? ne(slackSearchInstallation.id, existing.id) : undefined ) ) - .returning({ id: credential.id }) - if (!updated) + .limit(1) + if (active) throw new OrchestrationError( 'conflict', - 'Slack bot credential no longer belongs to this organization' + 'This Slack workspace already has an active Search installation' ) - } else { - await tx.insert(credential).values({ - id: credentialId, - organizationId: context.organizationId, - workspaceId: null, - type: 'service_account', - providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, - createdBy: principal.userId, - ...credentialValues, - }) - } - const installationValues = { - ...identity, - slackAppId: identity.appId, - credentialVersion: slackBotCredentialVersion(encryptedToken, appRevision), - enabled: true, - revision: generateId(), - lastOutcome: null, - lastEventAt: null, - updatedAt: new Date(), - } - if (existing) - await tx - .update(slackSearchInstallation) - .set(installationValues) - .where(eq(slackSearchInstallation.id, existing.id)) - else - await tx.insert(slackSearchInstallation).values({ - id: generateId(), + if (attempt.sharedApp) { + /** A concurrent failed setup may have revoked an uncommitted grant while we waited. */ + const current = await verifySlackSearchBot( + grant.access_token, + AbortSignal.timeout(10_000), + SLACK_SHARED_SEARCH_BOT_SCOPES + ) + if ( + current.appId !== identity.appId || + current.teamId !== identity.teamId || + current.botUserId !== identity.botUserId + ) + throw new OrchestrationError( + 'validation', + 'Slack installation identity changed during setup' + ) + } + const appRevision = attempt.sharedApp?.revision ?? generateId() + const appValues = { + id: identity.appId, + kind: 'custom' as const, organizationId: context.organizationId, - credentialId, - ...installationValues, - }) - }) + clientId: attempt.clientId, + encryptedClientSecret: attempt.encryptedClientSecret, + encryptedSigningSecret: attempt.encryptedSigningSecret, + revision: appRevision, + updatedAt: new Date(), + } + if (!attempt.sharedApp) + await tx + .insert(slackApp) + .values(appValues) + .onConflictDoUpdate({ target: slackApp.id, set: appValues }) + await adoptOrganizationSlackMemberApp( + tx, + context.organizationId, + identity.appId, + identity.teamId, + attempt.clientId + ) + if (attempt.sharedApp) + await configureSharedSlackMemberApp(tx, { + organizationId: context.organizationId, + userId: principal.userId, + appId: identity.appId, + teamId: identity.teamId, + }) + const credentialId = existing?.credentialId ?? generateId() + const credentialValues = { + slackAppId: identity.appId, + displayName: attempt.name, + description: attempt.description, + encryptedServiceAccountKey: encryptedToken, + updatedAt: new Date(), + } + if (existing) { + const [updated] = await tx + .update(credential) + .set(credentialValues) + .where( + and( + eq(credential.id, credentialId), + eq(credential.organizationId, context.organizationId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) + ) + .returning({ id: credential.id }) + if (!updated) + throw new OrchestrationError( + 'conflict', + 'Slack bot credential no longer belongs to this organization' + ) + } else { + await tx.insert(credential).values({ + id: credentialId, + organizationId: context.organizationId, + workspaceId: null, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + createdBy: principal.userId, + ...credentialValues, + }) + } + const installationValues = { + ...identity, + slackAppId: identity.appId, + credentialVersion: slackBotCredentialVersion(encryptedToken, appRevision), + enabled: true, + revision: generateId(), + lastOutcome: null, + lastEventAt: null, + updatedAt: new Date(), + } + if (existing) + await tx + .update(slackSearchInstallation) + .set(installationValues) + .where(eq(slackSearchInstallation.id, existing.id)) + else + await tx.insert(slackSearchInstallation).values({ + id: generateId(), + organizationId: context.organizationId, + credentialId, + ...installationValues, + }) + }) + } catch (error) { + if (attempt.sharedApp) await revokeUninstalledSharedGrant(grant) + throw error + } return { organizationId: context.organizationId } }, projectAudit: ({ context }) => ({ diff --git a/apps/sim/lib/knowledge/application/slack-search/stop.ts b/apps/sim/lib/knowledge/application/slack-search/stop.ts index b2918094d64..86813ee3cf6 100644 --- a/apps/sim/lib/knowledge/application/slack-search/stop.ts +++ b/apps/sim/lib/knowledge/application/slack-search/stop.ts @@ -23,7 +23,7 @@ import { slackSearchConversation, slackSearchConversationKey, } from '@/lib/slack-search/conversation' -import { slackSearchJobSchema } from '@/lib/slack-search/types' +import { slackSearchJobSchema, slackSearchThreadTimestamp } from '@/lib/slack-search/types' import { setSlackAgentSessionStatus } from '@/lib/webhooks/slack-agent-api' const timestamp = z.string().regex(/^\d{1,12}\.\d{1,9}$/) @@ -81,7 +81,7 @@ export const stopSlackSearchThread: OperationUseCase< slackSearchConversationKey( job.installationId, job.message.channelId, - job.message.threadTs ?? job.message.messageTs + slackSearchThreadTimestamp(job.message) ) !== conversationKey ) throw new Error('Persisted Slack event has an inconsistent conversation identity') diff --git a/apps/sim/lib/knowledge/application/slack-search/turns.ts b/apps/sim/lib/knowledge/application/slack-search/turns.ts index b235b739d35..2c9cf165fa4 100644 --- a/apps/sim/lib/knowledge/application/slack-search/turns.ts +++ b/apps/sim/lib/knowledge/application/slack-search/turns.ts @@ -29,6 +29,10 @@ export function slackSearchTurnOutboxId(turnId: string) { /** Commits the turn and its retryable dispatch together; only dispatch, never execution, is retried. */ export async function persistSlackSearchTurn(job: SlackSearchJob, expectedUserId?: string) { return db.transaction(async (tx) => { + if (job.message.command) { + await tx.execute(sql`SET LOCAL statement_timeout = '1500ms'`) + await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`) + } const [installation] = await tx .select() .from(slackSearchInstallation) @@ -55,33 +59,34 @@ export async function persistSlackSearchTurn(job: SlackSearchJob, expectedUserId ) ) .limit(1) - const conversation = slackSearchConversation(job) - const conversationKey = slackSearchConversationKey( - installation.id, - conversation.channelId, - conversation.threadTs - ) - if (duplicate && job.message.origin) { + const conversation = job.message.messageTs === null ? null : slackSearchConversation(job) + const conversationKey = conversation + ? slackSearchConversationKey(installation.id, conversation.channelId, conversation.threadTs) + : `slack-command:${installation.id}:${job.message.eventId}` + if (duplicate && (job.message.origin || job.message.command)) { const original = slackSearchJobSchema.parse(duplicate.payload) if ( original.message.userId !== job.message.userId || original.message.query !== job.message.query || original.message.queryTooLong !== job.message.queryTooLong || - JSON.stringify(original.message.origin) !== JSON.stringify(job.message.origin) + JSON.stringify(original.message.origin) !== JSON.stringify(job.message.origin) || + original.message.command !== job.message.command ) throw new OrchestrationError('forbidden', 'Slack event identity changed') return duplicate.id } if (duplicate && duplicate.conversationKey !== conversationKey) throw new OrchestrationError('forbidden', 'Slack event conversation changed') - await requireSlackSearchConversationSender(tx, conversation) - const chat = expectedUserId - ? await resolveSlackSearchChatRecord(tx, { - organizationId: installation.organizationId, - userId: expectedUserId, - conversation, - }) - : await findSlackSearchChatRecord(tx, conversation) + if (conversation) await requireSlackSearchConversationSender(tx, conversation) + const chat = !conversation + ? null + : expectedUserId + ? await resolveSlackSearchChatRecord(tx, { + organizationId: installation.organizationId, + userId: expectedUserId, + conversation, + }) + : await findSlackSearchChatRecord(tx, conversation) if ( chat && (chat.organizationId !== installation.organizationId || diff --git a/apps/sim/lib/knowledge/application/workspace-search.activity.test.ts b/apps/sim/lib/knowledge/application/workspace-search.activity.test.ts new file mode 100644 index 00000000000..32ecacf0059 --- /dev/null +++ b/apps/sim/lib/knowledge/application/workspace-search.activity.test.ts @@ -0,0 +1,144 @@ +/** @vitest-environment node */ +import { member } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + context: vi.fn(), + policy: vi.fn(), + findIndex: vi.fn(), + available: vi.fn(), + activity: vi.fn(), + search: vi.fn(), +})) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeOrganizationContext: mocks.context, + resolveKnowledgeOwnerContext: mocks.context, + resolveKnowledgeWorkspaceContext: mocks.context, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.policy, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + isOrgAdminRole: (role: string) => role === 'admin' || role === 'owner', +})) +vi.mock('@/lib/knowledge/search/search-index', () => ({ + findSearchIndex: mocks.findIndex, + findWorkspaceSearchIndex: mocks.findIndex, +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireOrganizationSearchAvailable: mocks.available, +})) +vi.mock('@/lib/knowledge/search/activity', () => ({ + recordOrganizationSearchActivity: mocks.activity, +})) +vi.mock('@/lib/knowledge/application/search', () => ({ + searchKnowledge: { execute: mocks.search }, +})) + +import { + searchOrganizationKnowledge, + searchScopedKnowledge, +} from '@/lib/knowledge/application/workspace-search' + +const principal = { kind: 'session', userId: 'reader', sessionId: 'session' } as const +const input = { organizationId: 'org', query: 'policy', topK: 20, surface: 'slack' } as const + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.context.mockResolvedValue({ organizationId: 'org' }) + mocks.policy.mockResolvedValue(null) + mocks.findIndex.mockResolvedValue(null) + mocks.available.mockResolvedValue(undefined) + mocks.activity.mockResolvedValue(undefined) + mocks.search.mockResolvedValue({ results: [], knowledgeBases: [{ id: 'index' }] }) +}) + +describe.each([ + { name: 'organization Assistant', operation: searchOrganizationKnowledge }, + { name: 'scoped Search', operation: searchScopedKnowledge }, +])('$name activity before an index exists', ({ operation }) => { + it('records an authorized empty invocation for the acting member', async () => { + queueTableRows(member, [{ role: 'member' }]) + expect(await operation.execute({ principal, input })).toEqual({ + results: [], + query: 'policy', + knowledgeBases: [], + }) + expect(mocks.available).toHaveBeenCalledExactlyOnceWith('org') + expect(mocks.activity).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org', + userId: 'reader', + surface: 'slack', + results: [], + }) + expect(mocks.search).not.toHaveBeenCalled() + }) + + it('leaves indexed invocation metering to the canonical search operation', async () => { + queueTableRows(member, [{ role: 'member' }]) + mocks.findIndex.mockResolvedValueOnce({ id: 'index' }) + await operation.execute({ principal, input }) + expect(mocks.search).toHaveBeenCalledOnce() + expect(mocks.search).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ knowledgeBaseIds: ['index'], surface: 'slack' }), + }) + expect(mocks.activity).not.toHaveBeenCalled() + }) + + it('does not meter an unavailable Search request', async () => { + queueTableRows(member, [{ role: 'member' }]) + mocks.available.mockRejectedValueOnce(new Error('Search is disabled')) + await expect(operation.execute({ principal, input })).rejects.toThrow('Search is disabled') + expect(mocks.activity).not.toHaveBeenCalled() + expect(mocks.search).not.toHaveBeenCalled() + }) + + it('does not discover the index or meter a nonmember request', async () => { + queueTableRows(member, []) + await expect(operation.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + expect(mocks.findIndex).not.toHaveBeenCalled() + expect(mocks.activity).not.toHaveBeenCalled() + }) + + it('does not meter a request that was already cancelled', async () => { + queueTableRows(member, [{ role: 'member' }]) + const controller = new AbortController() + controller.abort(new Error('Search cancelled')) + await expect( + operation.execute({ principal, input: { ...input, signal: controller.signal } }) + ).rejects.toThrow('Search cancelled') + expect(mocks.activity).not.toHaveBeenCalled() + expect(mocks.search).not.toHaveBeenCalled() + }) + + it.each(['index', 'availability'] as const)( + 'does not meter a request cancelled during the %s lookup', + async (lookup) => { + queueTableRows(member, [{ role: 'member' }]) + const controller = new AbortController() + const cancel = () => controller.abort(new Error('Search cancelled')) + if (lookup === 'index') { + mocks.findIndex.mockImplementationOnce(async () => { + cancel() + return null + }) + } else { + mocks.available.mockImplementationOnce(async () => { + cancel() + }) + } + + await expect( + operation.execute({ principal, input: { ...input, signal: controller.signal } }) + ).rejects.toThrow('Search cancelled') + expect(mocks.findIndex).toHaveBeenCalledOnce() + expect(mocks.activity).not.toHaveBeenCalled() + expect(mocks.search).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/knowledge/application/workspace-search.ts b/apps/sim/lib/knowledge/application/workspace-search.ts index 67598b11c7d..4c44f62cf0e 100644 --- a/apps/sim/lib/knowledge/application/workspace-search.ts +++ b/apps/sim/lib/knowledge/application/workspace-search.ts @@ -1,4 +1,6 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOrganizationContext, @@ -7,6 +9,7 @@ import { } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { type SearchKnowledgeInput, searchKnowledge } from '@/lib/knowledge/application/search' +import { recordOrganizationSearchActivity } from '@/lib/knowledge/search/activity' import { findSearchIndex, findWorkspaceSearchIndex } from '@/lib/knowledge/search/search-index' export type SearchWorkspaceKnowledgeInput = Omit< @@ -22,6 +25,7 @@ export const searchWorkspaceKnowledge = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: SearchWorkspaceKnowledgeInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ principal, input, context }) { + input.signal?.throwIfAborted() const index = await findWorkspaceSearchIndex(context.workspaceId) if (!index) return { results: [], query: input.query ?? '', knowledgeBases: [] } return searchKnowledge.execute({ @@ -42,11 +46,26 @@ export const searchOrganizationKnowledge = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: SearchOrganizationKnowledgeInput }) => resolveKnowledgeOrganizationContext(input), async execute({ principal, input, context }) { + input.signal?.throwIfAborted() const index = await findSearchIndex({ kind: 'organization', organizationId: context.organizationId, }) - if (!index) return { results: [], query: input.query ?? '', knowledgeBases: [] } + if (!index) { + if (context.organizationId) { + await requireOrganizationSearchAvailable(context.organizationId) + input.signal?.throwIfAborted() + const userId = resolvePrincipalSubjectUserId(principal) + if (userId) + await recordOrganizationSearchActivity({ + organizationId: context.organizationId, + userId, + surface: input.surface ?? 'other', + results: [], + }) + } + return { results: [], query: input.query ?? '', knowledgeBases: [] } + } return searchKnowledge.execute({ principal, input: { ...input, knowledgeBaseIds: [index.id] } }) }, }) @@ -63,8 +82,23 @@ export const searchScopedKnowledge = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: SearchScopedKnowledgeInput }) => resolveKnowledgeOwnerContext(input), async execute({ principal, input, context }) { + input.signal?.throwIfAborted() const index = await findSearchIndex(resourceScopeFromOwner(context)) - if (!index) return { results: [], query: input.query ?? '', knowledgeBases: [] } + if (!index) { + if (context.organizationId) { + await requireOrganizationSearchAvailable(context.organizationId) + input.signal?.throwIfAborted() + const userId = resolvePrincipalSubjectUserId(principal) + if (userId) + await recordOrganizationSearchActivity({ + organizationId: context.organizationId, + userId, + surface: input.surface ?? 'other', + results: [], + }) + } + return { results: [], query: input.query ?? '', knowledgeBases: [] } + } return searchKnowledge.execute({ principal, input: { diff --git a/apps/sim/lib/knowledge/connectors/access-modes.ts b/apps/sim/lib/knowledge/connectors/access-modes.ts index 4293150e5a0..4c57b41f09a 100644 --- a/apps/sim/lib/knowledge/connectors/access-modes.ts +++ b/apps/sim/lib/knowledge/connectors/access-modes.ts @@ -93,3 +93,20 @@ export function effectiveConnectorSyncIntervalMinutes( ? Math.min(configuredMinutes, MAX_PERMISSION_REFRESH_INTERVAL_MINUTES) : configuredMinutes } + +/** Provider restrictions apply consistently to setup and explicit access changes. */ +export function supportsConnectorAccessMode( + connector: { supportedAccessModes?: readonly ConnectorAccessMode[] }, + mode: ConnectorAccessMode +): boolean { + return !connector.supportedAccessModes || connector.supportedAccessModes.includes(mode) +} + +export function initialConnectorAccessMode( + connector: { supportedAccessModes?: readonly ConnectorAccessMode[] } | undefined, + preferred: ConnectorAccessMode +): ConnectorAccessMode { + return connector && !supportsConnectorAccessMode(connector, preferred) + ? (connector.supportedAccessModes?.[0] ?? preferred) + : preferred +} diff --git a/apps/sim/lib/knowledge/connectors/external-group-sync.ts b/apps/sim/lib/knowledge/connectors/external-group-sync.ts index 02e6e7fad4b..230a5f92280 100644 --- a/apps/sim/lib/knowledge/connectors/external-group-sync.ts +++ b/apps/sim/lib/knowledge/connectors/external-group-sync.ts @@ -481,11 +481,13 @@ export async function refreshConnectorDirectory( ) } try { + const syncContext = syncContextForToken(token) + await connectorConfig.permissionConfig?.populateSyncContext(connector.id, syncContext) const outcome = await refreshMirroredDirectory({ ...resourceScopeFields(resourceScopeFromOwner(connector)), connectorConfig, sourceConfig, - syncContext: syncContextForToken(token), + syncContext, accessToken: token.accessToken, force: connector.lastSyncError?.startsWith(DIRECTORY_ERROR_PREFIX), }) diff --git a/apps/sim/lib/knowledge/connectors/manual-sync-cooldown.test.ts b/apps/sim/lib/knowledge/connectors/manual-sync-cooldown.test.ts new file mode 100644 index 00000000000..9db0e5e5aca --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/manual-sync-cooldown.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { assertManualSyncCooldown } from '@/lib/knowledge/connectors/manual-sync-cooldown' + +const NOW = new Date('2026-09-01T12:00:00Z') +const CONNECTOR = { + status: 'active', + memberSyncStatus: 'idle', + lastSyncError: null, + lastMemberSyncError: null, +} + +describe.each(['content', 'member'] as const)('manual %s sync cooldown', (kind) => { + const log = + kind === 'content' + ? schemaMock.knowledgeConnectorSyncLog + : schemaMock.knowledgeConnectorMemberSyncLog + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + resetDbChainMock() + }) + afterEach(() => { + vi.useRealTimers() + resetDbChainMock() + }) + + it('locks the connector before reading its latest successful completion', async () => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(log, [ + { status: 'completed', completedAt: new Date(NOW.getTime() - 10_000), failures: 0 }, + ]) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).rejects.toMatchObject({ + code: 'conflict', + message: 'Sync finished recently. Try again in 50 seconds.', + }) + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.from.mock.invocationCallOrder[1] + ) + expect(dbChainMockFns.orderBy).toHaveBeenCalledOnce() + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(2, 1) + }) + + it.each([60_000, 60_001])('allows a completed run after %i milliseconds', async (age) => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(log, [ + { status: 'completed', completedAt: new Date(NOW.getTime() - age), failures: 0 }, + ]) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).resolves.toBeUndefined() + }) + + it.each(['failed', 'partial', 'started'])( + 'allows recovery when the latest run is %s', + async (status) => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(log, [{ status, completedAt: NOW, failures: 0 }]) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).resolves.toBeUndefined() + } + ) + + it.each([null, new Date('invalid')])( + 'does not invent a cooldown without a valid completion time', + async (completedAt) => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(log, [{ status: 'completed', completedAt, failures: 0 }]) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).resolves.toBeUndefined() + } + ) + + it('allows the first sync and legacy completed runs with failures', async () => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(log, []) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).resolves.toBeUndefined() + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) + queueTableRows(log, [{ status: 'completed', completedAt: NOW, failures: 1 }]) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).resolves.toBeUndefined() + }) + + it('permits retry after failed queue handoff without requiring a new run log', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { + ...CONNECTOR, + ...(kind === 'content' + ? { status: 'error', lastSyncError: 'Sync could not be queued' } + : { memberSyncStatus: 'error', lastMemberSyncError: 'Sync could not be queued' }), + }, + ]) + await expect(assertManualSyncCooldown(db, 'connector-1', kind)).resolves.toBeUndefined() + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(log) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/manual-sync-cooldown.ts b/apps/sim/lib/knowledge/connectors/manual-sync-cooldown.ts new file mode 100644 index 00000000000..6c0e96675d6 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/manual-sync-cooldown.ts @@ -0,0 +1,64 @@ +import type { db } from '@sim/db' +import { + knowledgeConnector, + knowledgeConnectorMemberSyncLog, + knowledgeConnectorSyncLog, +} from '@sim/db/schema' +import { desc, eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const MANUAL_SYNC_COOLDOWN_MS = 60_000 + +/** + * Called inside the pending-claim transaction. The connector lock is also held + * when a run completes, so its latest verdict cannot change before admission. + */ +export async function assertManualSyncCooldown( + tx: Pick, + connectorId: string, + kind: 'content' | 'member' +): Promise { + const [connector] = await tx + .select({ + status: knowledgeConnector.status, + memberSyncStatus: knowledgeConnector.memberSyncStatus, + lastSyncError: knowledgeConnector.lastSyncError, + lastMemberSyncError: knowledgeConnector.lastMemberSyncError, + }) + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connectorId)) + .for('update') + .limit(1) + + if ( + !connector || + connector.status !== 'active' || + (kind === 'member' + ? connector.memberSyncStatus !== 'idle' || connector.lastMemberSyncError + : connector.lastSyncError) + ) + return + + const log = kind === 'content' ? knowledgeConnectorSyncLog : knowledgeConnectorMemberSyncLog + const [latest] = await tx + .select({ + status: log.status, + completedAt: log.completedAt, + failures: + kind === 'content' + ? knowledgeConnectorSyncLog.docsFailed + : knowledgeConnectorMemberSyncLog.membersFailed, + }) + .from(log) + .where(eq(log.connectorId, connectorId)) + .orderBy(desc(log.startedAt)) + .limit(1) + + if (latest?.status !== 'completed' || !latest.completedAt || latest.failures > 0) return + const remainingMs = latest.completedAt.getTime() + MANUAL_SYNC_COOLDOWN_MS - Date.now() + if (remainingMs > 0) + throw new OrchestrationError( + 'conflict', + `Sync finished recently. Try again in ${Math.ceil(remainingMs / 1000)} seconds.` + ) +} diff --git a/apps/sim/lib/knowledge/connectors/member-queue.test.ts b/apps/sim/lib/knowledge/connectors/member-queue.test.ts index 544e122e39b..aad272ce0a4 100644 --- a/apps/sim/lib/knowledge/connectors/member-queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/member-queue.test.ts @@ -118,6 +118,55 @@ describe('member sync queue', () => { }) describe('dispatchMemberSync', () => { + it('rejects a rapid manual repeat without making members due', async () => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) + queueTableRows(schemaMock.knowledgeConnectorMemberSyncLog, [ + { status: 'completed', completedAt: new Date(), failures: 0 }, + ]) + await expect( + dispatchMemberSync('c-1', { billingAttribution: BILLING, manual: true }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockTrigger).not.toHaveBeenCalled() + expect(mockExecuteMemberSync).not.toHaveBeenCalled() + }) + + it('makes active members due only after taking the manual pending claim', async () => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) + queueTableRows(schemaMock.knowledgeConnectorMemberSyncLog, []) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + await expect( + dispatchMemberSync('c-1', { billingAttribution: BILLING, manual: true }) + ).resolves.toEqual({ queued: true }) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).toHaveBeenNthCalledWith(1, schemaMock.knowledgeConnector) + expect(dbChainMockFns.update).toHaveBeenNthCalledWith(2, schemaMock.knowledgeConnectorMember) + expect(dbChainMockFns.returning.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.update.mock.invocationCallOrder[1] + ) + expect(dbChainMockFns.set).toHaveBeenLastCalledWith({ + nextAttemptAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + expect(mockTrigger).toHaveBeenCalledOnce() + }) + + it('does not reschedule members if a concurrent lifecycle change declines the claim', async () => { + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) + queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) + queueTableRows(schemaMock.knowledgeConnectorMemberSyncLog, []) + queueTableRows(schemaMock.knowledgeConnector, [{ ...CONNECTOR_ROW, status: 'paused' }]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + await expect( + dispatchMemberSync('c-1', { billingAttribution: BILLING, manual: true }) + ).resolves.toMatchObject({ queued: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.knowledgeConnectorMember) + expect(mockTrigger).not.toHaveBeenCalled() + }) + it('takes the queue entry and hands the run to the queue with its token', async () => { queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR_ROW]) dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) diff --git a/apps/sim/lib/knowledge/connectors/member-queue.ts b/apps/sim/lib/knowledge/connectors/member-queue.ts index d08c8143442..398463d9a87 100644 --- a/apps/sim/lib/knowledge/connectors/member-queue.ts +++ b/apps/sim/lib/knowledge/connectors/member-queue.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { knowledgeBase, knowledgeConnector, knowledgeConnectorMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -16,6 +16,7 @@ import { import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { assertManualSyncCooldown } from '@/lib/knowledge/connectors/manual-sync-cooldown' import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine' import { SYNC_DISPATCH_FAILED_ERROR, @@ -49,6 +50,8 @@ export interface MemberSyncPayload { } export interface DispatchMemberSyncOptions { + /** Manual requests wait briefly after a successful run and make every active member due. */ + manual?: boolean billingAttribution: BillingAttributionSnapshot /** The scheduled instant this dispatch was made for; a changed schedule makes it stale. */ expectedNextMemberSyncAt?: Date @@ -100,34 +103,51 @@ export function assertMemberSyncPayload(value: unknown): MemberSyncPayload { */ async function markMemberSyncPending( connectorId: string, - expectedNextMemberSyncAt: Date | undefined + expectedNextMemberSyncAt: Date | undefined, + manual: boolean ): Promise { const dispatchToken = generateId() - const now = new Date() - const taken = await db - .update(knowledgeConnector) - .set({ - memberSyncStatus: 'pending', - memberSyncLockToken: dispatchToken, - memberSyncLockLeaseAt: now, - updatedAt: now, - }) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.accessMode, 'members'), - inArray(knowledgeConnector.status, MEMBER_LOCKABLE_CONNECTOR_STATUSES), - inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES), - ...(expectedNextMemberSyncAt - ? [eq(knowledgeConnector.nextMemberSyncAt, expectedNextMemberSyncAt)] - : []), - isNull(knowledgeConnector.memberSyncLockToken), - isNull(knowledgeConnector.syncLockToken), - connectorIsLive() + const claim = async (tx: Pick) => { + if (manual) await assertManualSyncCooldown(tx, connectorId, 'member') + const now = new Date() + const taken = await tx + .update(knowledgeConnector) + .set({ + memberSyncStatus: 'pending', + memberSyncLockToken: dispatchToken, + memberSyncLockLeaseAt: now, + updatedAt: now, + }) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.accessMode, 'members'), + inArray(knowledgeConnector.status, MEMBER_LOCKABLE_CONNECTOR_STATUSES), + inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES), + ...(expectedNextMemberSyncAt + ? [eq(knowledgeConnector.nextMemberSyncAt, expectedNextMemberSyncAt)] + : []), + isNull(knowledgeConnector.memberSyncLockToken), + isNull(knowledgeConnector.syncLockToken), + connectorIsLive() + ) ) - ) - .returning({ id: knowledgeConnector.id }) - return taken.length > 0 ? dispatchToken : null + .returning({ id: knowledgeConnector.id }) + if (taken.length === 0) return null + if (manual) { + await tx + .update(knowledgeConnectorMember) + .set({ nextAttemptAt: now, updatedAt: now }) + .where( + and( + eq(knowledgeConnectorMember.connectorId, connectorId), + eq(knowledgeConnectorMember.status, 'active') + ) + ) + } + return dispatchToken + } + return manual ? db.transaction(claim) : claim(db) } async function describeUnacceptedMemberSync( @@ -304,7 +324,11 @@ export async function dispatchMemberSync( } assertBillingAttributionOwner(payload.billingAttribution, row) - const dispatchToken = await markMemberSyncPending(connectorId, options.expectedNextMemberSyncAt) + const dispatchToken = await markMemberSyncPending( + connectorId, + options.expectedNextMemberSyncAt, + options.manual === true + ) if (!dispatchToken) { const reason = await describeUnacceptedMemberSync(connectorId, options.expectedNextMemberSyncAt) logger.info('Skipping member sync dispatch: connector is not accepting a queued run', { diff --git a/apps/sim/lib/knowledge/connectors/permission-config.server.ts b/apps/sim/lib/knowledge/connectors/permission-config.server.ts new file mode 100644 index 00000000000..67f769b17bb --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/permission-config.server.ts @@ -0,0 +1,59 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { + ConnectorPermissionSummary, + PrepareConnectorPermissionsInput, +} from '@/lib/knowledge/connectors/permission-config' + +export async function hasConnectorPermissionConfig(connectorType: string): Promise { + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + return Boolean(CONNECTOR_REGISTRY[connectorType]?.permissionConfig) +} + +export async function prepareConnectorPermissions( + connectorType: string, + input: PrepareConnectorPermissionsInput +) { + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + const capability = CONNECTOR_REGISTRY[connectorType]?.permissionConfig + if (input.permissionConfig?.provider && input.permissionConfig.provider !== connectorType) { + throw new OrchestrationError( + 'validation', + 'The permission configuration does not match the connector provider.' + ) + } + if (!capability) { + if (input.permissionConfig || (input.existing && input.apiKey !== undefined)) { + throw new OrchestrationError( + 'validation', + 'This connector does not support private permission settings.' + ) + } + return undefined + } + return capability.prepare(input) +} + +export async function readConnectorPermissionSummary(connectorType: string, connectorId: string) { + return (await readConnectorPermissionSummaries([{ connectorType, id: connectorId }])).get( + connectorId + ) +} + +export async function readConnectorPermissionSummaries( + connectors: readonly { connectorType: string; id: string }[] +) { + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + const groups = new Map() + for (const row of connectors) { + if (!CONNECTOR_REGISTRY[row.connectorType]?.permissionConfig) continue + const ids = groups.get(row.connectorType) ?? [] + ids.push(row.id) + groups.set(row.connectorType, ids) + } + const summaries = new Map() + for (const [type, ids] of groups) { + const values = await CONNECTOR_REGISTRY[type].permissionConfig!.readSummaries(ids) + for (const [id, summary] of values) summaries.set(id, summary) + } + return summaries +} diff --git a/apps/sim/lib/knowledge/connectors/permission-config.ts b/apps/sim/lib/knowledge/connectors/permission-config.ts new file mode 100644 index 00000000000..828fd7ef0c4 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/permission-config.ts @@ -0,0 +1,39 @@ +import type { DbTransaction } from '@/lib/db/types' +import type { + GitLabPermissionInput, + GitLabPermissionSummary, +} from '@/connectors/gitlab/permission-config/types' + +/** Provider-discriminated private input; it is never persisted in sourceConfig. */ +export type ConnectorPermissionConfig = { provider: 'gitlab' } & GitLabPermissionInput +export type ConnectorPermissionSummary = { provider: 'gitlab' } & GitLabPermissionSummary + +export interface PrepareConnectorPermissionsInput { + accessMode: string + sourceConfig: Record + permissionConfig?: ConnectorPermissionConfig + apiKey?: string + existing?: { + id: string + encryptedApiKey: string | null + sourceConfig: unknown + } +} + +/** Server-created transaction participant; request data can never supply these callbacks. */ +export interface PreparedConnectorPermissions { + requiresAclReset: boolean + requiresContentSync: boolean + encryptedApiKey?: string + populateSyncContext(context: Record, connectorId: string): void + write(tx: DbTransaction, connectorId: string): Promise +} + +/** Providers own private configuration and grants; orchestration owns the surrounding transaction. */ +export interface ConnectorPermissionConfigCapability { + prepare( + input: PrepareConnectorPermissionsInput + ): Promise + readSummaries(connectorIds: readonly string[]): Promise> + populateSyncContext(connectorId: string, context: Record): Promise +} diff --git a/apps/sim/lib/knowledge/connectors/permission-store.ts b/apps/sim/lib/knowledge/connectors/permission-store.ts new file mode 100644 index 00000000000..c6f4a389165 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/permission-store.ts @@ -0,0 +1,96 @@ +import { db } from '@sim/db' +import { + knowledgeConnectorPermissionGrant, + knowledgeConnectorPermissionSnapshot, +} from '@sim/db/schema' +import { eq, inArray } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' + +export type ConnectorPermissionSnapshot = typeof knowledgeConnectorPermissionSnapshot.$inferSelect + +/** Call only after authorizing the connector; this materializes its private normalized input. */ +export async function loadConnectorPermissionSnapshot(connectorId: string) { + const [row] = await db + .select() + .from(knowledgeConnectorPermissionSnapshot) + .where(eq(knowledgeConnectorPermissionSnapshot.connectorId, connectorId)) + .limit(1) + return row ?? null +} + +/** Metadata and worker reads leave the potentially large payload in PostgreSQL. */ +export async function readConnectorPermissionMetadata(connectorIds: readonly string[]) { + const table = knowledgeConnectorPermissionSnapshot + const result: Array> = + [] + for (let offset = 0; offset < connectorIds.length; offset += 1000) { + result.push( + ...(await db + .select({ + connectorId: table.connectorId, + revision: table.revision, + metadata: table.metadata, + }) + .from(table) + .where(inArray(table.connectorId, connectorIds.slice(offset, offset + 1000)))) + ) + } + return result +} + +/** The caller holds the connector row lock and commits configuration, credentials and grants together. */ +export async function writeConnectorPermissions( + tx: DbTransaction, + connectorId: string, + input: { + expectedRevision: number + metadata: Record + payload: Record + groups: readonly { groupKey: string; subjects: readonly string[] }[] + } +) { + const table = knowledgeConnectorPermissionSnapshot + const [current] = await tx + .select({ revision: table.revision }) + .from(table) + .where(eq(table.connectorId, connectorId)) + .limit(1) + if ((current?.revision ?? 0) !== input.expectedRevision) { + throw new OrchestrationError( + 'conflict', + 'Permissions changed. Reload the connection before saving.' + ) + } + const values = { + revision: input.expectedRevision + 1, + metadata: input.metadata, + payload: input.payload, + } + try { + await tx + .insert(table) + .values({ connectorId, ...values }) + .onConflictDoUpdate({ target: table.connectorId, set: values }) + await tx + .delete(knowledgeConnectorPermissionGrant) + .where(eq(knowledgeConnectorPermissionGrant.connectorId, connectorId)) + for (const group of input.groups) { + for (let offset = 0; offset < group.subjects.length; offset += 1000) { + await tx + .insert(knowledgeConnectorPermissionGrant) + .values( + group.subjects + .slice(offset, offset + 1000) + .map((subjectToken) => ({ connectorId, groupKey: group.groupKey, subjectToken })) + ) + } + } + } catch { + /** Query exceptions may contain bind parameters from the private payload. */ + throw new OrchestrationError( + 'internal', + 'Could not save permissions. The previous configuration was preserved.' + ) + } +} diff --git a/apps/sim/lib/knowledge/connectors/permission-tokens.ts b/apps/sim/lib/knowledge/connectors/permission-tokens.ts new file mode 100644 index 00000000000..d30d3b55a32 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/permission-tokens.ts @@ -0,0 +1,4 @@ +/** A private connector group cannot grant access to another connector or a provider directory group. */ +export function connectorPermissionGroupToken(connectorId: string, groupKey: string): string { + return `g:connector:${encodeURIComponent(connectorId)}:${encodeURIComponent(groupKey)}` +} diff --git a/apps/sim/lib/knowledge/connectors/queue.test.ts b/apps/sim/lib/knowledge/connectors/queue.test.ts index f225db2da9f..256d29c28a6 100644 --- a/apps/sim/lib/knowledge/connectors/queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/queue.test.ts @@ -102,6 +102,34 @@ describe('connector sync queue', () => { resetDbChainMock() }) + it.each([false, true])( + 'rejects a rapid manual repeat before queueing (rehydrate: %s)', + async (rehydrate) => { + queueTableRows(schemaMock.knowledgeConnector, [{ status: 'active', lastSyncError: null }]) + queueTableRows(schemaMock.knowledgeConnectorSyncLog, [ + { status: 'completed', completedAt: new Date(), failures: 0 }, + ]) + await expect( + dispatchSync('connector-1', { + billingAttribution: BILLING_ATTRIBUTION, + manual: true, + rehydrate, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockTrigger).not.toHaveBeenCalled() + expect(mockExecuteSync).not.toHaveBeenCalled() + } + ) + + it('does not consult manual cooldown history for automatic or initial dispatch', async () => { + await dispatchSync('connector-1', { billingAttribution: BILLING_ATTRIBUTION }) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.knowledgeConnectorSyncLog) + expect(mockTrigger).toHaveBeenCalledOnce() + }) + /** * The bug this pins: the queue once refused anything but workspace mode, so * every admin-mode connector the scheduler selected was dropped before the diff --git a/apps/sim/lib/knowledge/connectors/queue.ts b/apps/sim/lib/knowledge/connectors/queue.ts index abfca415c0e..595becf92ec 100644 --- a/apps/sim/lib/knowledge/connectors/queue.ts +++ b/apps/sim/lib/knowledge/connectors/queue.ts @@ -16,6 +16,7 @@ import { CONTENT_ENGINE_ACCESS_MODES, isContentEngineAccessMode, } from '@/lib/knowledge/connectors/access-modes' +import { assertManualSyncCooldown } from '@/lib/knowledge/connectors/manual-sync-cooldown' import { executeSync, isConnectorRunnableStatus } from '@/lib/knowledge/connectors/sync-engine' import { connectorIsLive, LOCKABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' @@ -49,6 +50,8 @@ export interface ConnectorSyncPayload { } export interface DispatchSyncOptions { + /** Manual requests wait briefly after a successful run before starting another. */ + manual?: boolean billingAttribution: BillingAttributionSnapshot expectedNextSyncAt?: Date fullSync?: boolean @@ -151,30 +154,34 @@ export interface SyncDispatchResult { * it takes nothing, so the caller can skip a hand-off that would only be refused * at the lock. */ -async function markSyncPending(connectorId: string): Promise { +async function markSyncPending(connectorId: string, manual: boolean): Promise { const dispatchToken = generateId() - const now = new Date() - const taken = await db - .update(knowledgeConnector) - .set({ - status: 'pending', - syncLockToken: dispatchToken, - syncLockLeaseAt: now, - updatedAt: now, - }) - .where( - and( - eq(knowledgeConnector.id, connectorId), - inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), - inArray(knowledgeConnector.status, LOCKABLE_CONNECTOR_STATUSES), - isNull(knowledgeConnector.syncLockToken), - connectorIsLive() + const claim = async (tx: Pick) => { + if (manual) await assertManualSyncCooldown(tx, connectorId, 'content') + const now = new Date() + const taken = await tx + .update(knowledgeConnector) + .set({ + status: 'pending', + syncLockToken: dispatchToken, + syncLockLeaseAt: now, + updatedAt: now, + }) + .where( + and( + eq(knowledgeConnector.id, connectorId), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), + inArray(knowledgeConnector.status, LOCKABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.syncLockToken), + connectorIsLive() + ) ) - ) - .returning({ id: knowledgeConnector.id }) + .returning({ id: knowledgeConnector.id }) - return taken.length > 0 ? dispatchToken : null + return taken.length > 0 ? dispatchToken : null + } + return manual ? db.transaction(claim) : claim(db) } /** @@ -397,7 +404,7 @@ export async function dispatchSync( ] if (isTriggerAvailable()) { - const dispatchToken = await markSyncPending(connectorId) + const dispatchToken = await markSyncPending(connectorId, options.manual === true) if (!dispatchToken) { const reason = await describeUnacceptedSync(connectorId) logger.info('Skipping sync dispatch: connector is not accepting a queued sync', { @@ -439,7 +446,7 @@ export async function dispatchSync( return { queued: true } } - const dispatchToken = await markSyncPending(connectorId) + const dispatchToken = await markSyncPending(connectorId, options.manual === true) if (!dispatchToken) { const reason = await describeUnacceptedSync(connectorId) logger.info('Skipping sync execution: connector is not accepting a queued sync', { diff --git a/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts b/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts index cec28ccf473..de2d738b106 100644 --- a/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts @@ -655,7 +655,7 @@ describe('Confluence empty content through the shared content pass', () => { it('explicitly rehydrates a skipped source whose version is unchanged', async () => { sourceBody = { value: '

Local content is rechecked

' } const { result, hydrate } = await runPass({ - existing: { ...EXISTING, contentHash: 'confluence:storage-local-body-v1:page:3' }, + existing: { ...EXISTING, contentHash: 'confluence:storage-local-body-v2:page:3' }, access: 'admin', readCurrent: true, forceRehydrate: true, @@ -669,7 +669,7 @@ describe('Confluence empty content through the shared content pass', () => { hydrationVersion = 3 await runPass({ existing: EXISTING, readCurrent: true, access: 'admin' }) const skipped = contentWrite() - expect(skipped.contentHash).toBe('confluence:storage-local-body-v1:page:3') + expect(skipped.contentHash).toBe('confluence:storage-local-body-v2:page:3') hydrationVersion = undefined sourceBody = { value: '

Current version content

' } diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 61a6fdbe07b..dc11c0d278d 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -276,7 +276,7 @@ describe('resolveTagMapping', () => { describe('classifyExternalDoc', () => { const base = { content: 'hello', contentDeferred: false, contentHash: 'h1' } - it('records a new skipped file as a failed row', async () => { + it('records a new skipped file as an intentional exclusion', async () => { const { classifyExternalDoc } = await import('@/lib/knowledge/connectors/sync-primitives') expect( classifyExternalDoc({ ...base, content: '', skippedReason: 'too big' }, undefined) @@ -2107,6 +2107,25 @@ describe('completeSyncLog', () => { }) }) +describe('isContentPassIncomplete', () => { + it('is true only when the listing has not finished or a source read failed', async () => { + const { isContentPassIncomplete } = await import('@/lib/knowledge/connectors/sync-engine') + const checkpoint = { startedAt: '2026-09-04T00:00:00Z', listedCount: 4 } + for (const complete of [true, false]) { + for (const unsafe of [true, false]) { + for (const contentFailures of [true, false, undefined]) { + expect( + isContentPassIncomplete({ + complete, + checkpoint: { ...checkpoint, unsafe, contentFailures }, + }) + ).toBe(!complete || contentFailures === true) + } + } + } + }) +}) + describe('completeSuccessfulSync', () => { const RESULT = { docsAdded: 1, @@ -2203,6 +2222,48 @@ describe('completeSuccessfulSync', () => { expect((connectorUpdate.nextSyncAt as Date).getTime()).toBeGreaterThan(Date.now() + 50 * 60_000) }) + it('records a held listing as a completed sync whose watermark advances', async () => { + const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) + queueTableRows(schemaMock.document, [{ count: 4 }]) + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'log-1' }]) + .mockResolvedValueOnce([{ id: 'c-1' }]) + const holdNotice = 'Source listing is incomplete; unlisted documents were kept.' + + expect( + await completeSuccessfulSync( + 'c-1', + 'kb-1', + 'log-1', + 60, + { ...RESULT, docsFailed: 0 }, + holdNotice, + { + complete: true, + checkpoint: { + unsafe: true, + contentFailures: false, + startedAt: '2026-09-04T00:00:00Z', + listedCount: 4, + }, + } + ) + ).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed', docsFailed: 0, listedCount: 4 }) + ) + const connectorUpdate = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.status === 'active' + )?.[0] as Record + expect(connectorUpdate.lastSyncAt).toEqual(new Date('2026-09-04T00:00:00Z')) + expect(connectorUpdate.lastSyncError).toBe(holdNotice) + expect(connectorUpdate.listingCheckpoint).toBeNull() + expect((connectorUpdate.nextSyncAt as Date).getTime()).toBeGreaterThan(Date.now() + 50 * 60_000) + }) + it('does not publish connector state when the guarded log close is refused', async () => { const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 5cfb6a2d08a..d1e9ef65b85 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -289,6 +289,32 @@ class SyncCompletionOwnershipLost extends Error { } } +/** What a finished content pass reports to the sync-log and connector close. */ +export interface ContentPassOutcome { + complete: boolean + checkpoint: { + unsafe: boolean + contentFailures?: boolean + startedAt: string + listedCount: number + incrementalSince?: string | null + } +} + +/** + * A content pass is incomplete when the listing has not reached the end of the + * source (the generation resumes on the next run) or a source read failed (the + * next pass replays it). `checkpoint.unsafe` is deliberately not part of this: + * it means "do not infer deletions from this listing" and is honored by the + * deletion hold in `reconcileCompletedListing`. A held pass is still a + * completed sync whose watermark advances. + */ +export function isContentPassIncomplete( + contentPass: Pick +): boolean { + return !contentPass.complete || contentPass.checkpoint.contentFailures === true +} + /** * Atomically publishes the completed log and connector terminal state. * @@ -303,16 +329,7 @@ export async function completeSuccessfulSync( syncIntervalMinutes: number, result: SyncResult, reconciliationHoldNotice: string | null, - contentPass?: { - complete: boolean - checkpoint: { - unsafe: boolean - contentFailures?: boolean - startedAt: string - listedCount: number - incrementalSince?: string | null - } - } + contentPass?: ContentPassOutcome ): Promise { try { return await db.transaction(async (tx) => { @@ -361,13 +378,7 @@ export async function completeSuccessfulSync( const [closedLog] = await tx .update(knowledgeConnectorSyncLog) .set({ - status: - contentPass && - (!contentPass.complete || - contentPass.checkpoint.unsafe || - contentPass.checkpoint.contentFailures) - ? 'partial' - : 'completed', + status: contentPass && isContentPassIncomplete(contentPass) ? 'partial' : 'completed', completedAt: now, listedCount: contentPass?.complete ? contentPass.checkpoint.incrementalSince @@ -398,19 +409,12 @@ export async function completeSuccessfulSync( actualDocCount, contentPass && !contentPass.complete ? now : calculateNextSyncTime(syncIntervalMinutes), reconciliationHoldNotice, - result.docsFailed === 0 && - (!contentPass || - (contentPass.complete && - !contentPass.checkpoint.unsafe && - !contentPass.checkpoint.contentFailures)) + result.docsFailed === 0 && (!contentPass || !isContentPassIncomplete(contentPass)) ), /** Restored above under this same lock, or hidden by the admin pass before the ACLs it wrote. */ accessRewritePending: false, ...(contentPass?.complete ? { listingCheckpoint: null } : {}), - ...(contentPass?.complete && - !contentPass.checkpoint.unsafe && - !contentPass.checkpoint.contentFailures && - result.docsFailed === 0 + ...(contentPass && !isContentPassIncomplete(contentPass) && result.docsFailed === 0 ? { lastSyncAt: new Date(contentPass.checkpoint.startedAt) } : {}), }) @@ -950,6 +954,8 @@ export async function executeSync( /** Tells a connector to carry permissions with its listing; without it, none are read. */ ...(mirrored ? { mirrorsSourceAcls: true } : {}), } + if (mirrored) + await connectorConfig.permissionConfig?.populateSyncContext(connectorId, syncContext) // Shared cutoff for both the tombstone-retry bound below and the stuck-document // retry near the end of this sync — same RETRY_WINDOW_DAYS window, one computation. @@ -1117,10 +1123,7 @@ export async function executeSync( : undefined, }) - result.listingIncomplete = - !contentPass.complete || - contentPass.checkpoint.unsafe || - contentPass.checkpoint.contentFailures + result.listingIncomplete = isContentPassIncomplete(contentPass) const reconciliationHoldNotice = contentPass.holdNotice const directoryError = await directoryRefreshed if (directoryError) throw directoryError diff --git a/apps/sim/lib/knowledge/connectors/sync-persistence.ts b/apps/sim/lib/knowledge/connectors/sync-persistence.ts index 0dbdbe4935b..3598bd78e8f 100644 --- a/apps/sim/lib/knowledge/connectors/sync-persistence.ts +++ b/apps/sim/lib/knowledge/connectors/sync-persistence.ts @@ -258,7 +258,7 @@ export interface KnowledgeBaseOwner { userId: string } -/** Builds a content-less `failed` document row for a skipped (e.g. oversized) file. */ +/** Builds a content-less document row for an intentional source exclusion. */ function buildSkippedDocumentRow( knowledgeBaseId: string, connectorId: string, @@ -295,7 +295,7 @@ function buildSkippedDocumentRow( } /** - * Records source files that were intentionally not indexed as content-less `failed` + * Records source files that were intentionally not indexed as content-less * documents. New rows are inserted in bulk; authoritative skips replace stale rows. * This keeps the files visible in the knowledge base UI — with `processingError` * explaining why — instead of silently dropping them. The rows have no storage key, diff --git a/apps/sim/lib/knowledge/connectors/sync-primitives.ts b/apps/sim/lib/knowledge/connectors/sync-primitives.ts index 5dc93a8cc4a..719144ae011 100644 --- a/apps/sim/lib/knowledge/connectors/sync-primitives.ts +++ b/apps/sim/lib/knowledge/connectors/sync-primitives.ts @@ -339,6 +339,13 @@ export function mergeHydratedSkippedDocument( ): ExternalDocument { return { ...stub, + ...(hydrated.skippedExistingDisposition === 'replace' + ? { + title: hydrated.title, + sourceUrl: hydrated.sourceUrl, + acl: hydrated.acl, + } + : {}), content: '', contentHash: hydrated.skippedRetryContentHash ?? @@ -346,7 +353,10 @@ export function mergeHydratedSkippedDocument( contentDeferred: false, skippedReason: hydrated.skippedReason, skippedExistingDisposition: hydrated.skippedExistingDisposition, - metadata: { ...stub.metadata, ...hydrated.metadata }, + metadata: + hydrated.skippedExistingDisposition === 'replace' + ? hydrated.metadata + : { ...stub.metadata, ...hydrated.metadata }, } } diff --git a/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts index 2c9041e4acb..924dd0f386b 100644 --- a/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts +++ b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts @@ -1,8 +1,6 @@ -import { db } from '@sim/db' -import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' -import { and, eq, inArray, isNull, or } from 'drizzle-orm' -import { resourceScopeCondition } from '@/lib/core/resource-scope.server' -import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE } from '@/lib/knowledge/constants' +import { credential, credentialGroup } from '@sim/db/schema' +import { and, eq, or } from 'drizzle-orm' +import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts' import { getConnectorMeta } from '@/connectors/registry' interface ViewerSourceAccount { @@ -52,36 +50,11 @@ export async function resolveViewerSourceAccounts(input: { }) const result = new Map() if (!matches.length) return result - const scope = { kind: 'organization', organizationId: input.organizationId } as const - const accounts = await db - .select({ - credentialId: credential.id, - displayName: credential.displayName, - status: credential.managedOauthStatus, - groupId: credentialGroup.id, - optionId: credential.credentialGroupOptionId, - providerId: credential.providerId, - }) - .from(credential) - .innerJoin( - credentialGroupEnrollment, - eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) - ) - .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) - .where( - and( - resourceScopeCondition(credential, scope), - resourceScopeCondition(credentialGroup, scope), - eq(credentialGroupEnrollment.userId, input.userId), - eq(credential.type, 'managed_oauth'), - inArray(credential.managedOauthStatus, ['active', 'needs_reauth']), - isNull(credential.revokedAt), - or(...matches) - ) - ) - .limit(SEARCH_SOURCE_CANDIDATE_PAGE_SIZE + 1) - if (accounts.length > SEARCH_SOURCE_CANDIDATE_PAGE_SIZE) - throw new Error('Too many personal accounts for the source page') + const accounts = await listViewerOrganizationAccounts({ + organizationId: input.organizationId, + userId: input.userId, + matching: or(...matches)!, + }) for (const { source, providerId } of bindings) { const own = accounts.filter((account) => source.accessMode === 'members' diff --git a/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts b/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts index 08669fa0a65..4ea7dc6b006 100644 --- a/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-outbox-handler.test.ts @@ -153,16 +153,17 @@ describe('knowledge document processing outbox handler', () => { ) }) - it.each([null, { ...DOCUMENT, processingStatus: 'completed' }])( - 'completes without redispatch when the document is absent or completed', - async (document) => { - mocks.getKnowledgeDocument.mockResolvedValueOnce(document) + it.each([ + null, + { ...DOCUMENT, processingStatus: 'completed' }, + { ...DOCUMENT, processingStatus: 'failed', processingOutcome: 'skipped' }, + ])('completes without redispatch when the document is absent or terminal', async (document) => { + mocks.getKnowledgeDocument.mockResolvedValueOnce(document) - await handler()(PAYLOAD, createContext()) + await handler()(PAYLOAD, createContext()) - expect(mocks.processDocumentsWithQueue).not.toHaveBeenCalled() - } - ) + expect(mocks.processDocumentsWithQueue).not.toHaveBeenCalled() + }) it('keeps the event retryable while an earlier processing attempt is active', async () => { const processingStartedAt = new Date() diff --git a/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts b/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts index 97691adacaf..e1ee59af466 100644 --- a/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts +++ b/apps/sim/lib/knowledge/documents/processing-outbox-handler.ts @@ -108,7 +108,12 @@ const processKnowledgeDocument: OutboxHandler = async (rawPayload, cont payload.documentId, SYSTEM_ACCESS_SCOPE ) - if (!document || document.processingStatus === 'completed') return + if ( + !document || + document.processingStatus === 'completed' || + document.processingOutcome === 'skipped' + ) + return if (document.processingStatus === 'processing') { const reclaimed = await reclaimStaleDocumentProcessingClaim({ knowledgeBaseId: payload.knowledgeBaseId, diff --git a/apps/sim/lib/knowledge/documents/processing-status.test.ts b/apps/sim/lib/knowledge/documents/processing-status.test.ts new file mode 100644 index 00000000000..db907f8da06 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-status.test.ts @@ -0,0 +1,44 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { getDocumentProcessingOutcome } from '@/lib/knowledge/documents/processing-status' +import { DOCUMENT_PROCESSING_STATUSES } from '@/lib/knowledge/documents/types' + +describe('document processing outcomes', () => { + const placeholder = { + processingStatus: 'failed', + externalId: 'source-file', + storageKey: null, + contentHash: 'known-version', + fileUrl: '', + } + + it('recognizes a legacy intentional skip without reading error text or contacting its source', () => { + expect(getDocumentProcessingOutcome(placeholder)).toBe('skipped') + }) + + it.each([ + { ...placeholder, contentHash: null }, + { ...placeholder, storageKey: 'kb/stored.txt' }, + { ...placeholder, externalId: null }, + { ...placeholder, fileUrl: 'https://fixture.test/stored.txt' }, + ])('preserves real source, indexing, and upload failures: %j', (row) => { + expect(getDocumentProcessingOutcome(row)).toBeNull() + }) + + it.each(DOCUMENT_PROCESSING_STATUSES)( + 'preserves a stored %s outcome with an artifact', + (status) => { + expect( + getDocumentProcessingOutcome({ + ...placeholder, + processingStatus: status, + storageKey: 'kb/stored.txt', + }) + ).toBeNull() + } + ) + + it('does not infer a source omission from an unknown state', () => { + expect(getDocumentProcessingOutcome({ ...placeholder, processingStatus: 'invalid' })).toBeNull() + }) +}) diff --git a/apps/sim/lib/knowledge/documents/processing-status.ts b/apps/sim/lib/knowledge/documents/processing-status.ts new file mode 100644 index 00000000000..b3fdb30579b --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-status.ts @@ -0,0 +1,52 @@ +import { document } from '@sim/db/schema' +import { and, eq, isNotNull, isNull, not, sql } from 'drizzle-orm' +import type { DocumentProcessingOutcome } from '@/lib/knowledge/documents/types' + +interface StoredDocumentProcessingState { + processingStatus: string + externalId: string | null + storageKey: string | null + contentHash: string | null + fileUrl: string +} + +/** + * Source omissions keep the legacy failed storage state for mixed-version app/worker + * compatibility. Their immutable source identity and version, with no stored or remote + * artifact, distinguish them from download failures (no version) and indexing failures + * (an artifact). Source identity survives disconnecting a connector with documents kept. + * The additive outcome lets current readers distinguish skips without rewriting old rows. + */ +export function getDocumentProcessingOutcome( + row: StoredDocumentProcessingState +): DocumentProcessingOutcome { + if ( + row.processingStatus === 'failed' && + row.externalId !== null && + row.storageKey === null && + row.fileUrl === '' && + row.contentHash !== null + ) { + return 'skipped' + } + return null +} + +/** SQL counterpart used for paging and aggregate counts before document rows are loaded. */ +export function skippedDocumentCondition() { + return and( + eq(document.processingStatus, 'failed'), + isNotNull(document.externalId), + isNull(document.storageKey), + eq(document.fileUrl, ''), + isNotNull(document.contentHash) + )! +} + +export function failedDocumentCondition() { + return and(eq(document.processingStatus, 'failed'), not(skippedDocumentCondition()))! +} + +export function documentProcessingOutcomeSelection() { + return sql`CASE WHEN ${skippedDocumentCondition()} THEN 'skipped' ELSE NULL END` +} diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index baa3a7f4309..d3dea4c4ecc 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -24,7 +24,7 @@ import { isNotNull, isNull, lt, - ne, + not, or, type SQL, sql, @@ -116,6 +116,11 @@ import { ProviderCapacityContinuationExhaustedError, } from '@/lib/knowledge/documents/processing-provider-deferral' import { scheduleDocumentProcessingQuotaContinuation } from '@/lib/knowledge/documents/processing-quota-continuation' +import { + documentProcessingOutcomeSelection, + getDocumentProcessingOutcome, + skippedDocumentCondition, +} from '@/lib/knowledge/documents/processing-status' import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' import { enqueueKnowledgeStorageCleanup, @@ -129,7 +134,10 @@ import { type TagFilterCondition, } from '@/lib/knowledge/documents/tag-filter' import { + type DocumentProcessingOutcome, + type DocumentProcessingStatus, type DocumentSortField, + isDocumentProcessingStatus, MAX_PROCESSING_ATTEMPTS, QUEUED_DISPATCH_GRACE_MS, type SortOrder, @@ -1494,7 +1502,8 @@ export async function processDocumentAsync( .where( and( eq(document.id, documentId), - ne(document.processingStatus, 'completed'), + inArray(document.processingStatus, ['pending', 'processing', 'failed']), + not(skippedDocumentCondition()), ...queueGenerationConditions(attemptContext), eq(document.userExcluded, false), isNull(document.archivedAt), @@ -1558,7 +1567,8 @@ export async function processDocumentAsync( .where( and( eq(document.id, documentId), - ne(document.processingStatus, 'completed'), + inArray(document.processingStatus, ['pending', 'processing', 'failed']), + not(skippedDocumentCondition()), ...(predecessor ? [or(and(...queueGenerationConditions(attemptContext)), predecessor)] : queueGenerationConditions(attemptContext)), @@ -2472,7 +2482,8 @@ export async function getDocuments( chunkCount: number tokenCount: number characterCount: number - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + processingStatus: DocumentProcessingStatus + processingOutcome: DocumentProcessingOutcome processingStartedAt: Date | null processingCompletedAt: Date | null processingError: string | null @@ -2579,6 +2590,7 @@ export async function getDocuments( tokenCount: document.tokenCount, characterCount: document.characterCount, processingStatus: document.processingStatus, + processingOutcome: documentProcessingOutcomeSelection(), processingStartedAt: document.processingStartedAt, processingCompletedAt: document.processingCompletedAt, processingError: document.processingError, @@ -2693,7 +2705,8 @@ export async function getDocuments( chunkCount: doc.chunkCount, tokenCount: doc.tokenCount, characterCount: doc.characterCount, - processingStatus: doc.processingStatus as 'pending' | 'processing' | 'completed' | 'failed', + processingStatus: doc.processingStatus as DocumentProcessingStatus, + processingOutcome: doc.processingOutcome, processingStartedAt: doc.processingStartedAt, processingCompletedAt: doc.processingCompletedAt, processingError: doc.processingError, @@ -2731,6 +2744,7 @@ export async function getDocuments( export type ActiveKnowledgeDocument = typeof document.$inferSelect & { connectorType: string | null + processingOutcome: DocumentProcessingOutcome } /** @@ -2747,6 +2761,7 @@ export async function getKnowledgeDocument( const [row] = await db .select({ ...getTableColumns(document), + processingOutcome: documentProcessingOutcomeSelection(), connectorType: knowledgeConnector.connectorType, }) .from(document) @@ -2775,6 +2790,7 @@ export async function getKnowledgeDocumentById( const [row] = await db .select({ ...getTableColumns(document), + processingOutcome: documentProcessingOutcomeSelection(), connectorType: knowledgeConnector.connectorType, }) .from(document) @@ -3065,7 +3081,7 @@ export async function getDocumentByUploadId( knowledgeBaseId: string ): Promise< | (Omit>, 'processingStatus'> & { - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + processingStatus: DocumentProcessingStatus }) | null > { @@ -3090,6 +3106,7 @@ export async function getDocumentByUploadId( tag6: document.tag6, tag7: document.tag7, processingStatus: document.processingStatus, + processingOutcome: documentProcessingOutcomeSelection(), }) .from(document) .where( @@ -3102,12 +3119,7 @@ export async function getDocumentByUploadId( .limit(1) if (!existing) return null const processingStatus = existing.processingStatus - if ( - processingStatus !== 'pending' && - processingStatus !== 'processing' && - processingStatus !== 'completed' && - processingStatus !== 'failed' - ) { + if (!isDocumentProcessingStatus(processingStatus)) { throw new Error(`Document ${existing.id} has invalid processing status`) } return { ...existing, processingStatus } @@ -3373,6 +3385,7 @@ export async function retryDocumentProcessing( and( eq(document.id, documentId), or(isNull(document.connectorId), isNotNull(document.contentHash)), + not(skippedDocumentCondition()), or( inArray(document.processingStatus, ['completed', 'failed']), and( @@ -3399,6 +3412,25 @@ export async function retryDocumentProcessing( }) if (!requeued) { + const [skipped] = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + skippedDocumentCondition() + ) + ) + .limit(1) + if (skipped) { + return { + success: false, + status: 'skipped', + message: + 'This source file was intentionally skipped. Sync the connector after changing the source.', + } + } const [sourceFailure] = await db .select({ id: document.id }) .from(document) @@ -3515,7 +3547,8 @@ export async function updateDocument( chunkCount: number tokenCount: number characterCount: number - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + processingStatus: DocumentProcessingStatus + processingOutcome: DocumentProcessingOutcome processingStartedAt: Date | null processingCompletedAt: Date | null processingError: string | null @@ -3546,7 +3579,7 @@ export async function updateDocument( chunkCount: number tokenCount: number characterCount: number - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + processingStatus: DocumentProcessingStatus processingError: string | null processingStartedAt: Date | null processingCompletedAt: Date | null @@ -3709,7 +3742,8 @@ export async function updateDocument( chunkCount: doc.chunkCount, tokenCount: doc.tokenCount, characterCount: doc.characterCount, - processingStatus: doc.processingStatus as 'pending' | 'processing' | 'completed' | 'failed', + processingStatus: doc.processingStatus as DocumentProcessingStatus, + processingOutcome: getDocumentProcessingOutcome(doc), processingStartedAt: doc.processingStartedAt, processingCompletedAt: doc.processingCompletedAt, processingError: doc.processingError, diff --git a/apps/sim/lib/knowledge/documents/types.ts b/apps/sim/lib/knowledge/documents/types.ts index eca8991a467..3b0bd225f99 100644 --- a/apps/sim/lib/knowledge/documents/types.ts +++ b/apps/sim/lib/knowledge/documents/types.ts @@ -95,6 +95,17 @@ export const DOCUMENT_PROCESSING_STATUSES = [ export type DocumentProcessingStatus = (typeof DOCUMENT_PROCESSING_STATUSES)[number] +/** An intentional source omission, separate from the backward-compatible processing state. */ +export type DocumentProcessingOutcome = 'skipped' | null + +/** Displays an intentional source outcome while accepting responses from older servers. */ +export function getDocumentIndexingStatus(document: { + processingStatus: string + processingOutcome?: DocumentProcessingOutcome +}): string { + return document.processingOutcome ?? document.processingStatus +} + /** * Narrows a stored `processing_status` onto the union. * diff --git a/apps/sim/lib/knowledge/github-setup-navigation.ts b/apps/sim/lib/knowledge/github-setup-navigation.ts new file mode 100644 index 00000000000..07e0a73d46a --- /dev/null +++ b/apps/sim/lib/knowledge/github-setup-navigation.ts @@ -0,0 +1,12 @@ +/** Setup can navigate only within Sim or to GitHub's approval pages. */ +export function resolveGitHubSetupUrl(value: string, origin: string): string { + const url = new URL(value, origin) + if ( + url.username || + url.password || + (url.origin !== origin && !(url.protocol === 'https:' && url.hostname === 'github.com')) + ) { + throw new Error('GitHub returned an invalid setup URL') + } + return url.href +} diff --git a/apps/sim/lib/knowledge/github-setup-state.ts b/apps/sim/lib/knowledge/github-setup-state.ts new file mode 100644 index 00000000000..deeff458dbd --- /dev/null +++ b/apps/sim/lib/knowledge/github-setup-state.ts @@ -0,0 +1,171 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { z } from 'zod' +import { getRedisClient } from '@/lib/core/config/redis' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export const GITHUB_SETUP_TTL_MS = 10 * 60 * 1000 +const scopeSchema = z + .object({ + organizationId: z.string().min(1).max(200), + setupId: z.string().uuid(), + userId: z.string().min(1).max(200), + sessionId: z.string().min(1).max(200), + }) + .strict() +export type GitHubSetupScope = z.infer + +const installationSchema = z + .object({ + installationId: z + .string() + .regex(/^[1-9]\d*$/) + .max(32), + accountId: z + .string() + .regex(/^[1-9]\d*$/) + .max(32), + accountLogin: z.string().min(1).max(100), + accountType: z.enum(['User', 'Organization']), + }) + .strict() +const baseSchema = scopeSchema.extend({ + createdAt: z.number().int().nonnegative(), + intent: z.literal('install').optional(), +}) +const attemptSchema = z.discriminatedUnion('phase', [ + baseSchema.extend({ phase: z.literal('starting') }), + baseSchema.extend({ phase: z.literal('oauth'), url: z.string().url().max(8192) }), + baseSchema.extend({ phase: z.literal('authorizing'), url: z.string().url().max(8192) }), + baseSchema.extend({ + phase: z.literal('choosing'), + installations: z.array(installationSchema).min(1).max(1000), + }), + baseSchema.extend({ phase: z.literal('installing'), state: z.string().uuid() }), + baseSchema.extend({ + phase: z.literal('connecting'), + installationId: z + .string() + .regex(/^[1-9]\d*$/) + .max(32), + state: z.string().uuid().optional(), + }), + baseSchema.extend({ + phase: z.literal('completed'), + credential: z + .object({ id: z.string().min(1).max(200), displayName: z.string().min(1).max(500) }) + .strict(), + }), + baseSchema.extend({ phase: z.literal('failed'), error: z.string().min(1).max(1000) }), + baseSchema.extend({ phase: z.literal('cancelled') }), +]) +export type GitHubSetupAttempt = z.infer +export type GitHubSetupPhase = GitHubSetupAttempt['phase'] + +function redis() { + const client = getRedisClient() + if (!client) throw new Error('GitHub setup requires Redis') + return client +} + +function attemptKey(scope: GitHubSetupScope) { + return `github-search:setup:${sha256Hex(JSON.stringify([scope.organizationId, scope.userId, scope.setupId]))}` +} + +function callbackKey(state: string) { + return `github-search:setup-callback:${sha256Hex(state)}` +} + +export function expiredGitHubSetupError() { + return new OrchestrationError('validation', 'GitHub setup expired or was canceled. Start again.') +} + +/** Retains the original expiry across refreshes and retries. */ +function remainingTtl(createdAt: number) { + const remaining = createdAt + GITHUB_SETUP_TTL_MS - Date.now() + if (createdAt > Date.now() || remaining <= 0) throw expiredGitHubSetupError() + return remaining +} + +export function githubSetupScope(attempt: GitHubSetupScope): GitHubSetupScope { + return scopeSchema.parse({ + organizationId: attempt.organizationId, + setupId: attempt.setupId, + userId: attempt.userId, + sessionId: attempt.sessionId, + }) +} + +const TRANSITION = ` +local raw = redis.call('GET', KEYS[1]) +if not raw then return 0 end +local current = cjson.decode(raw) +local next = cjson.decode(ARGV[1]) +if current.phase ~= ARGV[2] or current.userId ~= next.userId or current.sessionId ~= next.sessionId or current.organizationId ~= next.organizationId or current.setupId ~= next.setupId or current.createdAt ~= next.createdAt then return 0 end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3]) +return 1 +` + +/** Atomically claims a lifecycle transition; cancellation cannot be overwritten by stale work. */ +export async function saveGitHubSetupAttempt( + attempt: GitHubSetupAttempt, + expected: GitHubSetupPhase | 'new' +) { + const key = attemptKey(attempt) + const value = JSON.stringify(attemptSchema.parse(attempt)) + const ttl = remainingTtl(attempt.createdAt) + const saved = + expected === 'new' + ? (await redis().set(key, value, 'PX', ttl, 'NX')) === 'OK' + : (await redis().eval(TRANSITION, 1, key, value, expected, ttl)) === 1 + if (!saved) + throw new OrchestrationError( + 'conflict', + 'GitHub setup changed. Refresh to continue or start again.' + ) +} + +export async function readGitHubSetupAttempt( + scope: GitHubSetupScope +): Promise { + const raw = await redis().get(attemptKey(scope)) + if (!raw) return null + const attempt = attemptSchema.parse(JSON.parse(raw)) + if ( + attempt.organizationId !== scope.organizationId || + attempt.userId !== scope.userId || + attempt.setupId !== scope.setupId || + attempt.sessionId !== scope.sessionId || + attempt.createdAt > Date.now() || + Date.now() - attempt.createdAt >= GITHUB_SETUP_TTL_MS + ) + return null + return attempt +} + +/** Only a random, server-issued nonce is sent to GitHub; the owner remains in Redis. */ +export async function issueGitHubSetupCallback(attempt: GitHubSetupAttempt) { + const state = generateId() + const stored = await redis().set( + callbackKey(state), + JSON.stringify(githubSetupScope(attempt)), + 'PX', + remainingTtl(attempt.createdAt), + 'NX' + ) + if (stored !== 'OK') throw new Error('Could not create GitHub setup state') + return state +} + +/** A different browser session cannot consume or discover an attempt through its callback. */ +export async function resolveGitHubSetupCallback(state: string, principal: SessionPrincipal) { + const raw = await redis().get(callbackKey(state)) + if (!raw) throw expiredGitHubSetupError() + const scope = scopeSchema.parse(JSON.parse(raw)) + if (scope.userId !== principal.userId || scope.sessionId !== principal.sessionId) + throw expiredGitHubSetupError() + const attempt = await readGitHubSetupAttempt(scope) + if (!attempt) throw expiredGitHubSetupError() + return scope +} diff --git a/apps/sim/lib/knowledge/github-setup-urls.test.ts b/apps/sim/lib/knowledge/github-setup-urls.test.ts new file mode 100644 index 00000000000..97aa5587390 --- /dev/null +++ b/apps/sim/lib/knowledge/github-setup-urls.test.ts @@ -0,0 +1,64 @@ +/** @vitest-environment node */ +import { describe, expect, it, vi } from 'vitest' +import { + completeGitHubSearchSetupQuerySchema, + gitHubSearchSetupScopeSchema, + selectGitHubSearchSetupBodySchema, + startGitHubSearchSetupBodySchema, +} from '@/lib/api/contracts/knowledge/github-setup' + +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.example' })) + +import { + githubSetupCompletionUrl, + githubSetupContinueUrl, + githubSetupPageUrl, +} from '@/lib/knowledge/github-setup-urls' + +const scope = { + organizationId: 'org/with?special&characters', + setupId: '550e8400-e29b-41d4-a716-446655440000', +} +describe('GitHub setup URLs and contracts', () => { + it.each([githubSetupPageUrl, githubSetupContinueUrl])( + 'uses the configured origin and encodes scope as values', + (build) => { + const url = new URL(build(scope)) + expect(url.origin).toBe('https://sim.example') + expect(url.searchParams.get('organizationId')).toBe(scope.organizationId) + expect(url.searchParams.get('setupId')).toBe(scope.setupId) + expect(url.hash).toBe('') + } + ) + it('correlates the generic completion page without installation IDs or provider state', () => { + const url = new URL(githubSetupCompletionUrl(scope.setupId, 'denied')) + expect(url.pathname).toBe('/credential-groups/complete') + expect([...url.searchParams]).toEqual([ + ['completionId', scope.setupId], + ['oauth', 'denied'], + ]) + }) + it('admits explicit install intent only on start and accepts no client redirect target', () => { + const valid = { organizationId: 'org', setupId: scope.setupId } + expect( + startGitHubSearchSetupBodySchema.safeParse({ ...valid, intent: 'install' }).success + ).toBe(true) + expect(gitHubSearchSetupScopeSchema.safeParse({ ...valid, intent: 'install' }).success).toBe( + false + ) + expect( + startGitHubSearchSetupBodySchema.safeParse({ ...valid, returnTo: 'https://evil.example' }) + .success + ).toBe(false) + expect( + selectGitHubSearchSetupBodySchema.safeParse({ + ...valid, + action: { kind: 'select', installationId: '0' }, + }).success + ).toBe(false) + expect( + completeGitHubSearchSetupQuerySchema.safeParse({ state: 'guessed', installation_id: '42' }) + .success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/knowledge/github-setup-urls.ts b/apps/sim/lib/knowledge/github-setup-urls.ts new file mode 100644 index 00000000000..7d6b721aa44 --- /dev/null +++ b/apps/sim/lib/knowledge/github-setup-urls.ts @@ -0,0 +1,27 @@ +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' + +export function githubSetupPageUrl(scope: { organizationId: string; setupId: string }) { + const url = new URL('/knowledge/github/setup', getBaseUrl()) + url.searchParams.set('organizationId', scope.organizationId) + url.searchParams.set('setupId', scope.setupId) + return url.toString() +} + +export function githubSetupContinueUrl( + scope: { organizationId: string; setupId: string }, + oauth?: CredentialGroupOAuthFailure +) { + const url = new URL('/api/knowledge/github/setup/continue', getBaseUrl()) + url.searchParams.set('organizationId', scope.organizationId) + url.searchParams.set('setupId', scope.setupId) + if (oauth) url.searchParams.set('oauth', oauth) + return url.toString() +} + +export function githubSetupCompletionUrl(setupId: string, oauth?: CredentialGroupOAuthFailure) { + const url = new URL('/credential-groups/complete', getBaseUrl()) + url.searchParams.set('completionId', setupId) + if (oauth) url.searchParams.set('oauth', oauth) + return url.toString() +} diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index bc9baa09a74..0f4783471ac 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -108,6 +108,7 @@ vi.mock('@/connectors/registry.server', () => ({ }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { performCreateKnowledgeConnector, performDeleteKnowledgeConnector, @@ -430,6 +431,52 @@ describe('performUpdateKnowledgeConnector', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + it.each(['permissions', 'token'] as const)( + 'commits a %s-only change without dispatching a content sync', + async (change) => { + const existing = { + id: 'conn-1', + connectorType: 'gitlab', + accessMode: 'admin', + status: 'active', + updatedAt: new Date('2026-01-01T00:00:00Z'), + } + dbChainMockFns.limit.mockResolvedValueOnce([existing]) + dbChainMockFns.returning.mockResolvedValueOnce([existing]) + const write = vi.fn().mockResolvedValue(undefined) + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: existing.id, + updates: {}, + permissionChange: { + requiresAclReset: false, + requiresContentSync: false, + ...(change === 'token' ? { encryptedApiKey: 'encrypted-fixture-pat' } : {}), + populateSyncContext: vi.fn(), + write, + }, + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(write).toHaveBeenCalledWith(expect.anything(), existing.id) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ updatedFields: ['permissionConfig'] }), + }) + ) + expect(mockDispatchSync).not.toHaveBeenCalled() + expect(mockDispatchMemberSync).not.toHaveBeenCalled() + expect(resolveBillingAttribution).not.toHaveBeenCalled() + if (change === 'token') { + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ encryptedApiKey: 'encrypted-fixture-pat' }) + ) + } + } + ) + it('classifies a sub-hourly interval on an unentitled workspace as forbidden', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, @@ -1020,6 +1067,7 @@ describe('performSyncKnowledgeConnector', () => { billingAttribution: BILLING, requestId: 'req-1', rehydrate: true, + manual: true, }) expect(mockRecordAudit).toHaveBeenCalledWith( expect.objectContaining({ @@ -1064,6 +1112,33 @@ describe('performSyncKnowledgeConnector', () => { expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) + it.each(['workspace', 'members'] as const)( + 'preserves a transaction-wrapped cooldown conflict for %s without recording a sync', + async (accessMode) => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active', accessMode }, + ]) + const message = 'Sync finished recently. Try again in 60 seconds.' + const dispatch = accessMode === 'members' ? mockDispatchMemberSync : mockDispatchSync + dispatch.mockRejectedValueOnce( + new Error('Transaction failed', { cause: new OrchestrationError('conflict', message) }) + ) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(dispatch).toHaveBeenCalledOnce() + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict', error: message }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + } + ) + it('reports a failed dispatch instead of claiming the sync was queued', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'conn-1', connectorType: 'notion', status: 'active' }, @@ -1215,8 +1290,10 @@ describe('members-mode connectors', () => { expect(mockDispatchMemberSync).toHaveBeenCalledWith('c-1', { billingAttribution: BILLING, requestId: 'req-1', + manual: true, }) expect(mockDispatchSync).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.knowledgeConnectorMember) }) it('refuses a manual sync while a member run is queued or running', async () => { diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 8c8d44d4087..e7985025438 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -50,6 +50,7 @@ import { revokeKnowledgeConnectorCredentialAccess, stripListingCapFields, } from '@/lib/knowledge/connectors/member-access' +import type { PreparedConnectorPermissions } from '@/lib/knowledge/connectors/permission-config' import { allocateTagSlots } from '@/lib/knowledge/constants' import { enqueueKnowledgeStorageCleanup } from '@/lib/knowledge/documents/storage-cleanup' import { @@ -171,6 +172,7 @@ async function assertLiveSyncAllowed( } export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperationContext { + permissionChange?: PreparedConnectorPermissions knowledgeBase: ConnectorKnowledgeBase connectorType: string credentialId?: string @@ -320,11 +322,17 @@ export async function performCreateKnowledgeConnector( } if (accessToken !== null) { - const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig, { + const validationContext = { ...tokenContext, mirrorsSourceAcls: accessMode === 'admin', ...(accessMode === 'members' ? PER_MEMBER_LISTING_CONTEXT : {}), - }) + } + params.permissionChange?.populateSyncContext(validationContext, 'setup') + const configValidation = await connectorConfig.validateConfig( + accessToken, + sourceConfig, + validationContext + ) if (!configValidation.valid) { return fail( configValidation.error || @@ -539,6 +547,7 @@ export async function performCreateKnowledgeConnector( }) .returning() + await params.permissionChange?.write(tx, connectorId) return row }) } catch (error) { @@ -641,6 +650,8 @@ export async function performCreateKnowledgeConnector( } export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperationContext { + permissionChange?: PreparedConnectorPermissions + expectedUpdatedAt?: Date knowledgeBase: ConnectorKnowledgeBase connectorId: string updates: { @@ -711,19 +722,23 @@ export async function performUpdateKnowledgeConnector( const updatedFields = Object.keys(updates).filter( (key) => updates[key as keyof typeof updates] !== undefined ) + if (params.permissionChange) updatedFields.push('permissionConfig') if (updatedFields.length === 0) { - return fail( - 'At least one of sourceConfig, syncIntervalMinutes, or status is required', - 'validation' - ) + return fail('At least one connector setting or permission change is required', 'validation') } const existing = await getKnowledgeConnector(kb.id, connectorId) if (!existing) { return fail('Connector not found', 'not_found') } + if ( + params.expectedUpdatedAt && + params.expectedUpdatedAt.getTime() !== existing.updatedAt.getTime() + ) { + return fail('Connector changed during the update; reload before saving.', 'conflict') + } /** - * A running sync owns the row, so no edit is applied while it holds the lock. + * A running sync owns the content configuration; membership-only updates may still commit. * * `performSyncKnowledgeConnector` already refuses on the same condition; this * is the other half. `status: 'active'` sets `nextSyncAt = new Date()`, which @@ -732,7 +747,7 @@ export async function performUpdateKnowledgeConnector( * only two controls the UI leaves enabled on a wedged connector both pushed * its recovery out by another full TTL. * - * A non-status edit is refused too, not just a status flip. `sourceConfig` is + * Source-config and sync-interval edits are refused too. `sourceConfig` is * read once at the start of a run and threaded through it, so changing it * mid-flight yields a pass that lists against one config and reconciles * against another — and reconciliation hard-deletes. `syncIntervalMinutes` @@ -740,7 +755,14 @@ export async function performUpdateKnowledgeConnector( * so allowing it would silently discard the change. Refusing is the only * answer that is honest about either. */ - if (existing.status === 'syncing') { + const membershipOnly = Boolean( + params.permissionChange && + !params.permissionChange.requiresContentSync && + updates.sourceConfig === undefined && + updates.syncIntervalMinutes === undefined && + updates.status === undefined + ) + if (existing.status === 'syncing' && !membershipOnly) { return fail('Sync already in progress', 'conflict') } /** @@ -754,7 +776,9 @@ export async function performUpdateKnowledgeConnector( */ if ( existing.status === 'pending' && - (updates.sourceConfig !== undefined || updates.syncIntervalMinutes !== undefined) + (updates.sourceConfig !== undefined || + updates.syncIntervalMinutes !== undefined || + params.permissionChange?.requiresContentSync) ) { return fail('Sync already in progress', 'conflict') } @@ -825,9 +849,21 @@ export async function performUpdateKnowledgeConnector( } } + if ( + params.permissionChange?.requiresContentSync && + updates.sourceConfig === undefined && + validateSourceConfig + ) { + const rejection = await validateSourceConfig( + existing, + existing.sourceConfig as Record + ) + if (rejection) return fail(rejection.message, rejection.errorCode) + } + const resultingStatus = updates.status ?? existing.status const shouldDispatchSourceSync = - updates.sourceConfig !== undefined && + (updates.sourceConfig !== undefined || params.permissionChange?.requiresContentSync === true) && resultingStatus !== 'paused' && resultingStatus !== 'disabled' /** @@ -855,6 +891,14 @@ export async function performUpdateKnowledgeConnector( const values: Partial = { updatedAt: updateTimestamp, } + if (params.permissionChange?.encryptedApiKey) + values.encryptedApiKey = params.permissionChange.encryptedApiKey + if (params.permissionChange?.requiresAclReset) values.accessRewritePending = true + if (params.permissionChange?.requiresContentSync) { + values.lastSyncAt = null + values.listingCheckpoint = null + values.directoryCheckpoint = null + } if (sourceConfigToStore !== undefined) { values.sourceConfig = sourceConfigToStore values.lastSyncAt = null @@ -916,7 +960,7 @@ export async function performUpdateKnowledgeConnector( isNull(knowledgeConnector.deletedAt), ] updateConditions.push(eq(knowledgeConnector.status, existing.status)) - if (sourceConfigToStore !== undefined) + if (sourceConfigToStore !== undefined || params.permissionChange) updateConditions.push(eq(knowledgeConnector.updatedAt, existing.updatedAt)) if (syncsPerMember) { updateConditions.push(eq(knowledgeConnector.memberSyncStatus, existing.memberSyncStatus)) @@ -935,6 +979,7 @@ export async function performUpdateKnowledgeConnector( .set(values) .where(and(...updateConditions)) .returning() + if (updatedConnector) await params.permissionChange?.write(tx, connectorId) if (updatedConnector && syncsPerMember && sourceConfigToStore !== undefined) { await tx .update(knowledgeConnectorMember) @@ -1420,29 +1465,18 @@ export async function performSyncKnowledgeConnector( * outcome and both records describe what actually happened. */ try { - /** - * A manual run is meant to list everyone now, so every active member is - * made due; otherwise each waits out its own interval and the run claims - * nobody. - */ - if (connector.accessMode === 'members') { - await db - .update(knowledgeConnectorMember) - .set({ nextAttemptAt: new Date(), updatedAt: new Date() }) - .where( - and( - eq(knowledgeConnectorMember.connectorId, connectorId), - eq(knowledgeConnectorMember.status, 'active') - ) - ) - } const dispatch = connector.accessMode === 'members' - ? await (await loadDispatchMemberSync())(connectorId, { billingAttribution, requestId }) + ? await (await loadDispatchMemberSync())(connectorId, { + billingAttribution, + requestId, + manual: true, + }) : await (await loadDispatchSync())(connectorId, { billingAttribution, requestId, rehydrate, + manual: true, }) /** * A guard inside the dispatch declining to queue is reported as a failure diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index 7416255d29b..4436933ecfd 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -17,6 +17,10 @@ import { retryDocumentProcessing, updateDocument, } from '@/lib/knowledge/documents/service' +import type { + DocumentProcessingOutcome, + DocumentProcessingStatus, +} from '@/lib/knowledge/documents/types' import { auditActorFields, classifyKnowledgeFailure, @@ -65,7 +69,7 @@ export type CreatedKnowledgeDocument = Omit< 'processingStatus' > & { /** New documents are pending; idempotent completion may return a later persisted state. */ - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + processingStatus: DocumentProcessingStatus } export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperationContext { @@ -546,6 +550,7 @@ export interface PerformRetryKnowledgeDocumentParams { fileSize: number mimeType: string processingStatus: string + processingOutcome?: DocumentProcessingOutcome connectorId?: string | null contentHash?: string | null } @@ -560,6 +565,13 @@ export async function performRetryKnowledgeDocumentProcessing( const { knowledgeBaseId, document, billingAttribution } = params const requestId = params.requestId ?? generateRequestId() + if (document.processingOutcome === 'skipped') { + return fail( + 'This source file was intentionally skipped. Sync the connector after changing the source.', + 'validation' + ) + } + if (document.connectorId && document.contentHash === null) { return fail( 'Source content could not be downloaded. Sync the connector to retry.', @@ -603,7 +615,7 @@ export async function performRetryKnowledgeDocumentProcessing( // got off the ground leaves a dead document, and reporting that as success // paints the UI green over it. if (!result.success) { - return fail(result.message, 'internal') + return fail(result.message, result.status === 'skipped' ? 'validation' : 'internal') } return { success: true, status: result.status, message: result.message } } catch (error) { diff --git a/apps/sim/lib/knowledge/read-access.test.ts b/apps/sim/lib/knowledge/read-access.test.ts index 9d0d1179fab..47bda229ece 100644 --- a/apps/sim/lib/knowledge/read-access.test.ts +++ b/apps/sim/lib/knowledge/read-access.test.ts @@ -49,6 +49,21 @@ describe('knowledgeReadAccessBatches', () => { expect(gt).toHaveBeenCalledWith(document.connectorId, first.at(-1)!.connectorId) }) + it('yields only the ordinary predicate for a reader without live-source credentials', async () => { + const resolve = vi.fn(async () => identity) + const provider: KnowledgeAccessProvider = { + get: async () => identity, + getForConnectors: resolve, + getForDocuments: async () => identity, + hasLiveSourceReaders: async () => false, + } + const batches = [] + for await (const predicate of knowledgeReadAccessBatches(provider, [])) batches.push(predicate) + expect(batches).toHaveLength(1) + expect(resolve).not.toHaveBeenCalled() + expect(dbChainMockFns.selectDistinct).not.toHaveBeenCalled() + }) + it('does not enumerate sources after a satisfied ordinary existence probe', async () => { const resolve = vi.fn(async () => identity) const provider: KnowledgeAccessProvider = { diff --git a/apps/sim/lib/knowledge/read-access.ts b/apps/sim/lib/knowledge/read-access.ts index e1942b698a2..8a142217859 100644 --- a/apps/sim/lib/knowledge/read-access.ts +++ b/apps/sim/lib/knowledge/read-access.ts @@ -30,6 +30,7 @@ export async function* knowledgeReadAccessBatches( const ordinary = knowledgeAccessCondition(scope) yield ordinary if (!provider || scope.kind !== 'user') return + if (provider.hasLiveSourceReaders && !(await provider.hasLiveSourceReaders())) return let cursor: string | undefined while (true) { diff --git a/apps/sim/lib/knowledge/search/activity-stats.ts b/apps/sim/lib/knowledge/search/activity-stats.ts new file mode 100644 index 00000000000..29108baa858 --- /dev/null +++ b/apps/sim/lib/knowledge/search/activity-stats.ts @@ -0,0 +1,100 @@ +import { dbReplica } from '@sim/db' +import { organizationSearchInvocation, user } from '@sim/db/schema' +import { sql } from 'drizzle-orm' +import { + getSearchStatsWindow, + SEARCH_STATS_PEOPLE_LIMIT, + type SEARCH_STATS_PERIODS, + SEARCH_STATS_SOURCE_LIMIT, + type SEARCH_STATS_SURFACES, + type SearchStatsDateRange, +} from '@/lib/knowledge/search/stats' + +export interface SearchStatsInput extends SearchStatsDateRange { + organizationId: string + period: (typeof SEARCH_STATS_PERIODS)[number] + surface?: (typeof SEARCH_STATS_SURFACES)[number] +} + +type StatsRow = { + totals: { invocations: number; activePeople: number; results: number } + series: { timestamp: string; invocations: number }[] + surfaces: { surface: (typeof SEARCH_STATS_SURFACES)[number]; invocations: number }[] + sources: { sourceType: string; invocations: number }[] + people: { + userId: string | null + name: string | null + email: string | null + invocations: number + sourceTypes: string[] + }[] +} + +/** One statement snapshot; only bounded aggregates leave Postgres, never individual search records. */ +export async function loadOrganizationSearchStats(input: SearchStatsInput, now = new Date()) { + const { start, end, days } = getSearchStatsWindow(input.period, now, input) + const rows = await dbReplica.transaction( + async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '10s'`) + return tx.execute(sql` + WITH activity AS ( + SELECT id, user_id, surface, source_types, result_count, created_at + FROM ${organizationSearchInvocation} + WHERE organization_id = ${input.organizationId} + AND created_at >= ${start.toISOString()}::timestamptz + AND created_at < ${end.toISOString()}::timestamptz + ${input.surface ? sql`AND surface = ${input.surface}` : sql``} + ), people AS ( + SELECT user_id, count(*)::float8 AS invocations + FROM activity GROUP BY user_id + ORDER BY invocations DESC, user_id NULLS LAST + LIMIT ${SEARCH_STATS_PEOPLE_LIMIT} + ) + SELECT + (SELECT jsonb_build_object( + 'invocations', count(*)::float8, + 'activePeople', count(DISTINCT user_id)::float8, + 'results', coalesce(sum(result_count), 0)::float8 + ) FROM activity) AS totals, + (SELECT coalesce(jsonb_agg(row ORDER BY row.timestamp), '[]'::jsonb) FROM ( + SELECT to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') || 'T00:00:00.000Z' AS timestamp, + count(*)::float8 AS invocations + FROM activity GROUP BY 1 + ) row) AS series, + (SELECT coalesce(jsonb_agg(row ORDER BY row.invocations DESC, row.surface), '[]'::jsonb) FROM ( + SELECT surface, count(*)::float8 AS invocations FROM activity GROUP BY surface + ) row) AS surfaces, + (SELECT coalesce(jsonb_agg(row ORDER BY row.invocations DESC, row."sourceType"), '[]'::jsonb) FROM ( + SELECT source_type AS "sourceType", count(*)::float8 AS invocations + FROM activity CROSS JOIN LATERAL unnest(source_types) AS source_type + GROUP BY source_type ORDER BY invocations DESC, source_type + LIMIT ${SEARCH_STATS_SOURCE_LIMIT} + ) row) AS sources, + (SELECT coalesce(jsonb_agg(row ORDER BY row.invocations DESC, row."userId" NULLS LAST), '[]'::jsonb) FROM ( + SELECT people.user_id AS "userId", ${user.name} AS name, ${user.email} AS email, + people.invocations, + ARRAY( + SELECT DISTINCT source_type FROM activity + CROSS JOIN LATERAL unnest(source_types) AS source_type + WHERE activity.user_id IS NOT DISTINCT FROM people.user_id + ORDER BY source_type LIMIT ${SEARCH_STATS_SOURCE_LIMIT} + ) AS "sourceTypes" + FROM people LEFT JOIN ${user} ON ${user.id} = people.user_id + ) row) AS people + `) + }, + { accessMode: 'read only' } + ) + const row = rows[0] + if (!row) throw new Error('Search activity aggregate returned no result') + const byDay = new Map(row.series.map((point) => [point.timestamp, point.invocations])) + return { + ...row, + start: start.toISOString(), + end: end.toISOString(), + series: Array.from({ length: days }, (_, index) => { + const timestamp = new Date(start.getTime() + index * 86_400_000).toISOString() + return { timestamp, invocations: byDay.get(timestamp) ?? 0 } + }), + } +} diff --git a/apps/sim/lib/knowledge/search/activity.test.ts b/apps/sim/lib/knowledge/search/activity.test.ts new file mode 100644 index 00000000000..11eda26b4e9 --- /dev/null +++ b/apps/sim/lib/knowledge/search/activity.test.ts @@ -0,0 +1,98 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + values: vi.fn(), + insert: vi.fn(), + execute: vi.fn(), + transaction: vi.fn(), +})) +vi.mock('@sim/db', () => ({ db: { transaction: mocks.transaction } })) + +import { recordOrganizationSearchActivity } from '@/lib/knowledge/search/activity' + +beforeEach(() => { + vi.clearAllMocks() + mocks.insert.mockReturnValue({ values: mocks.values }) + mocks.values.mockResolvedValue(undefined) + mocks.execute.mockResolvedValue(undefined) + mocks.transaction.mockImplementation((callback) => + callback({ execute: mocks.execute, insert: mocks.insert }) + ) +}) + +describe('Search activity metering', () => { + it('counts documents once per invocation and stores no document identity or content', async () => { + await recordOrganizationSearchActivity({ + organizationId: 'org', + userId: 'actor', + surface: 'mcp', + results: [ + { documentId: 'private-document-1', connectorType: 'confluence' }, + { documentId: 'private-document-1', connectorType: 'confluence' }, + { documentId: 'private-document-2', connectorType: 'jira' }, + { documentId: 'private-document-3', connectorType: null }, + ], + }) + expect(mocks.values).toHaveBeenCalledWith({ + id: expect.any(String), + organizationId: 'org', + userId: 'actor', + surface: 'mcp', + sourceTypes: ['confluence', 'jira', 'uploads'], + resultCount: 3, + }) + expect(JSON.stringify(mocks.values.mock.calls)).not.toContain('private-document') + }) + it('records successful empty searches', async () => { + await recordOrganizationSearchActivity({ + organizationId: 'org', + userId: 'actor', + surface: 'dashboard', + results: [], + }) + expect(mocks.values).toHaveBeenCalledWith( + expect.objectContaining({ resultCount: 0, sourceTypes: [] }) + ) + }) + it('applies a transaction-local statement deadline before inserting activity', async () => { + const timeout = Promise.withResolvers() + mocks.execute.mockReturnValueOnce(timeout.promise) + const recorded = recordOrganizationSearchActivity({ + organizationId: 'org', + userId: 'actor', + surface: 'slack', + results: [], + }) + expect(mocks.insert).not.toHaveBeenCalled() + expect(JSON.stringify(mocks.execute.mock.calls[0])).toContain( + "SET LOCAL statement_timeout = '2s'" + ) + timeout.resolve() + await recorded + expect(mocks.insert).toHaveBeenCalledOnce() + }) + it('does not attempt an unbounded insert if setting the deadline fails', async () => { + mocks.execute.mockRejectedValueOnce(new Error('Could not set statement timeout')) + await expect( + recordOrganizationSearchActivity({ + organizationId: 'org', + userId: 'actor', + surface: 'slack', + results: [], + }) + ).resolves.toBeUndefined() + expect(mocks.insert).not.toHaveBeenCalled() + }) + it('does not fail Search when activity storage is unavailable', async () => { + mocks.values.mockRejectedValueOnce(new Error('offline')) + await expect( + recordOrganizationSearchActivity({ + organizationId: 'org', + userId: 'actor', + surface: 'dashboard', + results: [], + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/knowledge/search/activity.ts b/apps/sim/lib/knowledge/search/activity.ts new file mode 100644 index 00000000000..f3a68393de7 --- /dev/null +++ b/apps/sim/lib/knowledge/search/activity.ts @@ -0,0 +1,38 @@ +import { db } from '@sim/db' +import { organizationSearchInvocation } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { sql } from 'drizzle-orm' +import type { SEARCH_STATS_SURFACES } from '@/lib/knowledge/search/stats' + +const logger = createLogger('OrganizationSearchActivity') + +interface SearchActivityInput { + organizationId: string + userId: string + surface: (typeof SEARCH_STATS_SURFACES)[number] + results: ReadonlyArray<{ documentId: string; connectorType: string | null }> +} + +/** Records completed, authorized searches without query text, document IDs, or content. */ +export async function recordOrganizationSearchActivity(input: SearchActivityInput): Promise { + const sourceTypes = [...new Set(input.results.map((result) => result.connectorType ?? 'uploads'))] + try { + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '2s'`) + await tx.insert(organizationSearchInvocation).values({ + id: generateId(), + organizationId: input.organizationId, + userId: input.userId, + surface: input.surface, + sourceTypes, + resultCount: new Set(input.results.map((result) => result.documentId)).size, + }) + }) + } catch (error) { + logger.warn('Failed to record organization Search activity', { + error: getErrorMessage(error), + }) + } +} diff --git a/apps/sim/lib/knowledge/search/stats.test.ts b/apps/sim/lib/knowledge/search/stats.test.ts new file mode 100644 index 00000000000..3e232bc97ab --- /dev/null +++ b/apps/sim/lib/knowledge/search/stats.test.ts @@ -0,0 +1,97 @@ +/** @vitest-environment node */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { organizationSearchStatsQuerySchema } from '@/lib/api/contracts/knowledge/search-stats' +import { getSearchStatsRangeError, getSearchStatsWindow } from '@/lib/knowledge/search/stats' + +const now = new Date('2026-09-10T20:00:00.000Z') +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) +}) +afterEach(() => vi.useRealTimers()) + +describe('Search stats window and contract', () => { + it.each([ + { period: 'today', start: '2026-09-10T00:00:00.000Z', days: 1 }, + { period: '3d', start: '2026-09-08T00:00:00.000Z', days: 3 }, + { period: '7d', start: '2026-09-04T00:00:00.000Z', days: 7 }, + { period: '14d', start: '2026-08-28T00:00:00.000Z', days: 14 }, + { period: '30d', start: '2026-08-12T00:00:00.000Z', days: 30 }, + { period: '90d', start: '2026-06-13T00:00:00.000Z', days: 90 }, + ] as const)('uses $days UTC calendar days including today', ({ period, start, days }) => { + const now = new Date('2026-09-10T20:00:00.000Z') + const window = getSearchStatsWindow(period, now) + expect(window).toEqual({ start: new Date(start), end: now, days }) + }) + it('includes the last custom day with an exclusive next-midnight bound', () => { + expect( + getSearchStatsWindow('custom', now, { startDate: '2026-08-31', endDate: '2026-09-02' }) + ).toEqual({ + start: new Date('2026-08-31T00:00:00.000Z'), + end: new Date('2026-09-03T00:00:00.000Z'), + days: 3, + }) + expect( + getSearchStatsWindow('custom', now, { startDate: '2026-09-10', endDate: '2026-09-10' }) + ).toEqual({ + start: new Date('2026-09-10T00:00:00.000Z'), + end: now, + days: 1, + }) + }) + it.each([ + { startDate: '2026-09-01' }, + { endDate: '2026-09-01' }, + { startDate: '2026-02-29', endDate: '2026-03-01' }, + { startDate: '2026-04-31', endDate: '2026-05-01' }, + { startDate: '2026-9-01', endDate: '2026-09-02' }, + { startDate: '2026-09-03', endDate: '2026-09-02' }, + { startDate: '2026-09-10', endDate: '2026-09-11' }, + { startDate: '2026-06-12', endDate: '2026-09-10' }, + ])('rejects invalid or unbounded custom ranges: %j', (range) => { + expect(getSearchStatsRangeError(range, now)).not.toBeNull() + expect( + organizationSearchStatsQuerySchema.safeParse({ + organizationId: 'org', + period: 'custom', + ...range, + }).success + ).toBe(false) + expect(() => getSearchStatsWindow('custom', now, range)).toThrow() + }) + it('accepts leap days and exactly 90 inclusive days, but rejects dates with a preset', () => { + for (const range of [ + { startDate: '2024-02-29', endDate: '2024-03-01' }, + { startDate: '2026-06-13', endDate: '2026-09-10' }, + ]) { + expect( + organizationSearchStatsQuerySchema.safeParse({ + organizationId: 'org', + period: 'custom', + ...range, + }).success + ).toBe(true) + expect( + organizationSearchStatsQuerySchema.safeParse({ + organizationId: 'org', + period: '7d', + ...range, + }).success + ).toBe(false) + } + }) + it('bounds scans and rejects caller-supplied surfaces', () => { + expect( + organizationSearchStatsQuerySchema.safeParse({ organizationId: 'org', period: '365d' }) + .success + ).toBe(false) + expect( + organizationSearchStatsQuerySchema.safeParse({ organizationId: 'org', surface: 'forged' }) + .success + ).toBe(false) + expect(organizationSearchStatsQuerySchema.parse({ organizationId: 'org' })).toEqual({ + organizationId: 'org', + period: '30d', + }) + }) +}) diff --git a/apps/sim/lib/knowledge/search/stats.ts b/apps/sim/lib/knowledge/search/stats.ts new file mode 100644 index 00000000000..35dbf4ded42 --- /dev/null +++ b/apps/sim/lib/knowledge/search/stats.ts @@ -0,0 +1,74 @@ +export const SEARCH_STATS_PERIODS = ['today', '3d', '7d', '14d', '30d', '90d', 'custom'] as const +export const SEARCH_STATS_SURFACES = [ + 'dashboard', + 'copilot', + 'mcp', + 'slack', + 'api', + 'workflow', + 'other', +] as const +export const SEARCH_STATS_MAX_DAYS = 90 +const DAY_MS = 86_400_000 + +export const SEARCH_STATS_PEOPLE_LIMIT = 20 +export const SEARCH_STATS_SOURCE_LIMIT = 100 + +export const SEARCH_STATS_SURFACE_LABELS: Record<(typeof SEARCH_STATS_SURFACES)[number], string> = { + dashboard: 'Search', + copilot: 'Assistant', + mcp: 'MCP', + slack: 'Slack', + api: 'API', + workflow: 'Workflows', + other: 'Other', +} + +export interface SearchStatsDateRange { + startDate?: string + endDate?: string +} + +/** Checks the date-only UTC range shared by the picker, API, and aggregate query. */ +export function getSearchStatsRangeError( + range: SearchStatsDateRange, + now = new Date() +): string | null { + const { startDate, endDate } = range + if (!startDate || !endDate) return 'Select both a start date and an end date.' + for (const date of [startDate, endDate]) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return 'Use dates in YYYY-MM-DD format.' + const parsed = new Date(`${date}T00:00:00.000Z`) + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) { + return 'Select valid calendar dates.' + } + } + if (endDate < startDate) return 'The end date must be on or after the start date.' + if (endDate > now.toISOString().slice(0, 10)) return 'Select dates on or before today (UTC).' + const days = (Date.parse(endDate) - Date.parse(startDate)) / DAY_MS + 1 + if (days > SEARCH_STATS_MAX_DAYS) + return `Select a range of ${SEARCH_STATS_MAX_DAYS} days or fewer.` + return null +} + +/** Daily UTC buckets, with the selected end date included and today capped at the current time. */ +export function getSearchStatsWindow( + period: (typeof SEARCH_STATS_PERIODS)[number], + now = new Date(), + range: SearchStatsDateRange = {} +) { + if (period === 'custom') { + const error = getSearchStatsRangeError(range, now) + if (error) throw new Error(error) + const start = new Date(`${range.startDate}T00:00:00.000Z`) + const endDay = new Date(`${range.endDate}T00:00:00.000Z`) + const end = new Date(Math.min(endDay.getTime() + DAY_MS, now.getTime())) + const days = (endDay.getTime() - start.getTime()) / DAY_MS + 1 + return { start, end, days } + } + const daysByPeriod = { today: 1, '3d': 3, '7d': 7, '14d': 14, '30d': 30, '90d': 90 } as const + const days = daysByPeriod[period] + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) + start.setUTCDate(start.getUTCDate() - days + 1) + return { start, end: now, days } +} diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index bd8a8780ac1..af0a8625144 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -551,12 +551,11 @@ describe('knowledge base counts with live source permissions', () => { id: 'kb-1', workspaceId: 'ws-1', chunkingConfig: {}, - docCount: 99, - tokenCount: 999, + docCount: 2, + tokenCount: 10, createdAt: new Date('2026-01-01'), }, ]) - queueTableRows(schemaMock.document, [{ knowledgeBaseId: 'kb-1', docCount: 2, tokenCount: 10 }]) queueTableRows(schemaMock.document, [{ connectorId: 'confluence-source' }]) queueTableRows(schemaMock.document, [{ knowledgeBaseId: 'kb-1', docCount: 3, tokenCount: 20 }]) const result = await getWorkspaceKnowledgeBases('ws-1', 'active', { access, limit: 2 }) @@ -587,6 +586,56 @@ describe('knowledge base counts with live source permissions', () => { ).toBe(true) }) + it('counts an unpaged list in one joined query and one discovery pass bounded by the list filter', async () => { + const { access, getForConnectors } = reader() + const bases = Array.from({ length: 1000 }, (_, index) => ({ + id: `kb-${index}`, + workspaceId: 'ws-1', + chunkingConfig: {}, + docCount: 1, + tokenCount: 1, + createdAt: new Date('2026-01-01'), + })) + queueTableRows(schemaMock.knowledgeBase, bases) + queueTableRows(schemaMock.document, [{ connectorId: 'confluence-source' }]) + queueTableRows(schemaMock.document, [{ knowledgeBaseId: 'kb-7', docCount: 3, tokenCount: 20 }]) + const result = await getWorkspaceKnowledgeBases('ws-1', 'archived', { access }) + expect(result.data).toHaveLength(1000) + expect(result.data[7]).toMatchObject({ docCount: 4, tokenCount: 21 }) + expect(result.data[8]).toMatchObject({ docCount: 1, tokenCount: 1 }) + expect(getForConnectors).toHaveBeenCalledOnce() + expect(dbChainMockFns.selectDistinct).toHaveBeenCalledOnce() + expect(dbChainMockFns.groupBy).toHaveBeenCalledTimes(2) + expect( + dbChainMockFns.where.mock.calls.some(([condition]) => + hasMockCondition( + condition, + (node) => node.type === 'inArray' && node.column === schemaMock.knowledgeBase.id + ) + ) + ).toBe(false) + }) + + it('never discovers live sources for a reader without live-source credentials', async () => { + const { access, getForConnectors } = reader() + access.hasLiveSourceReaders = async () => false + queueTableRows(schemaMock.knowledgeBase, [ + { + id: 'kb-1', + workspaceId: 'ws-1', + chunkingConfig: {}, + docCount: 2, + tokenCount: 10, + createdAt: new Date('2026-01-01'), + }, + ]) + const result = await getWorkspaceKnowledgeBases('ws-1', 'archived', { access }) + expect(result.data[0]).toMatchObject({ docCount: 2, tokenCount: 10 }) + expect(getForConnectors).not.toHaveBeenCalled() + expect(dbChainMockFns.selectDistinct).not.toHaveBeenCalled() + expect(dbChainMockFns.groupBy).toHaveBeenCalledOnce() + }) + it('does not retain stale totals when a live source no longer authorizes its documents', async () => { const { access, getForConnectors } = reader() queueTableRows(schemaMock.document, []) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 0cff654809d..91352dbdef0 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -34,7 +34,7 @@ import { generateRestoreName } from '@/lib/core/utils/restore-name' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' -import { MAX_KNOWLEDGE_ACCESS_CANDIDATES } from '@/lib/knowledge/access/types' +import type { KnowledgeAccessProvider } from '@/lib/knowledge/access/types' import { mirrorsSourceAcls } from '@/lib/knowledge/connectors/access-modes' import { type KnowledgeReadAccess, knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import type { @@ -174,6 +174,7 @@ async function readKnowledgeBaseRows( ): Promise< Array> > { + const scope = access && 'get' in access ? await access.get() : access const query = db .select({ id: knowledgeBase.id, @@ -201,7 +202,7 @@ async function readKnowledgeBaseRows( eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - access ? ('get' in access ? sql`false` : knowledgeAccessCondition(access)) : undefined + scope ? knowledgeAccessCondition(scope) : undefined ) ) .where(where) @@ -210,59 +211,85 @@ async function readKnowledgeBaseRows( const rows = limit === undefined ? await query : await query.limit(limit) - const counts = - access && 'get' in access - ? await readKnowledgeBaseDocumentCounts( - rows.map((kb) => kb.id), + /** + * The join above already counted everything the reader's stored ACL admits. Only a + * provider can add documents a live source (GitHub, Confluence) authorizes beyond that, + * and that supplement is resolved once for the whole list: an unpaged list is bounded by + * its own filter, a page by its row IDs, so a workspace with tens of thousands of bases + * never turns into hundreds of per-batch round trips. + */ + const liveCounts = + access && 'get' in access && rows.length > 0 + ? await readLiveSourceDocumentCounts( + limit === undefined && where + ? where + : inArray( + knowledgeBase.id, + rows.map((kb) => kb.id) + ), access ) : undefined return rows.map((kb) => ({ ...kb, chunkingConfig: kb.chunkingConfig as ChunkingConfig, - docCount: counts ? (counts.get(kb.id)?.docCount ?? 0) : Number(kb.docCount), - tokenCount: counts ? (counts.get(kb.id)?.tokenCount ?? 0) : kb.tokenCount, + docCount: Number(kb.docCount) + (liveCounts?.get(kb.id)?.docCount ?? 0), + tokenCount: kb.tokenCount + (liveCounts?.get(kb.id)?.tokenCount ?? 0), })) } -/** Counts only hydrated access batches, keeping candidate discovery free of document metadata. */ -async function readKnowledgeBaseDocumentCounts( - knowledgeBaseIds: readonly string[], - access: KnowledgeReadAccess +const ACTIVE_DOCUMENT_CONDITIONS = [ + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), +] as const + +/** + * Document totals per knowledge base for one access predicate, restricted to the bases + * `subject` selects. `subject` may reference `knowledge_base` columns. + */ +async function countDocumentsByKnowledgeBase( + subject: SQL, + accessCondition: SQL +): Promise> { + return db + .select({ + knowledgeBaseId: document.knowledgeBaseId, + docCount: count(), + tokenCount: sql`COALESCE(SUM(${document.tokenCount}), 0)`.mapWith(Number), + }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where(and(subject, ...ACTIVE_DOCUMENT_CONDITIONS, accessCondition)) + .groupBy(document.knowledgeBaseId) +} + +/** + * Totals for documents only a live source authorizes, on top of the reader's stored ACL. + * The ordinary predicate is skipped because every caller has already counted it; candidate + * discovery stays free of document metadata and returns nothing for a reader without + * live-source credentials. + */ +async function readLiveSourceDocumentCounts( + subject: SQL, + access: KnowledgeAccessProvider ): Promise> { const counts = new Map() - for ( - let offset = 0; - offset < knowledgeBaseIds.length; - offset += MAX_KNOWLEDGE_ACCESS_CANDIDATES - ) { - const conditions = [ - inArray( - knowledgeBase.id, - knowledgeBaseIds.slice(offset, offset + MAX_KNOWLEDGE_ACCESS_CANDIDATES) - ), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt), - ] - for await (const accessCondition of knowledgeReadAccessBatches(access, conditions)) { - const rows = await db - .select({ - knowledgeBaseId: document.knowledgeBaseId, - docCount: count(), - tokenCount: sql`COALESCE(SUM(${document.tokenCount}), 0)`.mapWith(Number), - }) - .from(document) - .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) - .where(and(...conditions, accessCondition)) - .groupBy(document.knowledgeBaseId) - for (const row of rows) { - const previous = counts.get(row.knowledgeBaseId) - counts.set(row.knowledgeBaseId, { - docCount: (previous?.docCount ?? 0) + Number(row.docCount), - tokenCount: (previous?.tokenCount ?? 0) + Number(row.tokenCount), - }) - } + let ordinary = true + for await (const accessCondition of knowledgeReadAccessBatches(access, [ + subject, + ...ACTIVE_DOCUMENT_CONDITIONS, + ])) { + if (ordinary) { + ordinary = false + continue + } + for (const row of await countDocumentsByKnowledgeBase(subject, accessCondition)) { + const previous = counts.get(row.knowledgeBaseId) + counts.set(row.knowledgeBaseId, { + docCount: (previous?.docCount ?? 0) + Number(row.docCount), + tokenCount: (previous?.tokenCount ?? 0) + Number(row.tokenCount), + }) } } return counts @@ -1019,11 +1046,14 @@ export async function attachKnowledgeBaseConnectors( ): Promise { let visible = knowledgeBase if (access) { - const counts = await readKnowledgeBaseDocumentCounts([knowledgeBase.id], access) + const subject = eq(document.knowledgeBaseId, knowledgeBase.id) + const scope = 'get' in access ? await access.get() : access + const [ordinary] = await countDocumentsByKnowledgeBase(subject, knowledgeAccessCondition(scope)) + const live = 'get' in access ? await readLiveSourceDocumentCounts(subject, access) : undefined visible = { ...knowledgeBase, - docCount: counts.get(knowledgeBase.id)?.docCount ?? 0, - tokenCount: counts.get(knowledgeBase.id)?.tokenCount ?? 0, + docCount: Number(ordinary?.docCount ?? 0) + (live?.get(knowledgeBase.id)?.docCount ?? 0), + tokenCount: (ordinary?.tokenCount ?? 0) + (live?.get(knowledgeBase.id)?.tokenCount ?? 0), } } const [withConnectors] = await attachConnectorTypes([visible]) diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 18ddcc53c01..1aab5c59142 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -1,4 +1,8 @@ import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' +import type { + DocumentProcessingOutcome, + DocumentProcessingStatus, +} from '@/lib/knowledge/documents/types' import type { KbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' /** @@ -142,7 +146,8 @@ export interface DocumentData { chunkCount: number tokenCount: number characterCount: number - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + processingStatus: DocumentProcessingStatus + processingOutcome?: DocumentProcessingOutcome processingStartedAt?: string | null processingCompletedAt?: string | null processingError?: string | null diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 0521b0639a8..2e7a9ed6744 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -36,6 +36,7 @@ vi.mock('@sim/logger', () => ({ logger: mockLogger, runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), })) // Mock billing modules diff --git a/apps/sim/lib/mothership/conversation-id.test.ts b/apps/sim/lib/mothership/conversation-id.test.ts new file mode 100644 index 00000000000..24fa3f79eda --- /dev/null +++ b/apps/sim/lib/mothership/conversation-id.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { isValidUuid } from '@sim/utils/id' +import { describe, expect, it } from 'vitest' +import { resolveMothershipConversation } from '@/lib/mothership/conversation-id' + +const WORKSPACE_A = '11111111-1111-4111-8111-111111111111' +const WORKSPACE_B = '22222222-2222-4222-8222-222222222222' + +describe('resolveMothershipConversation', () => { + it('mints a fresh token when no conversation id is given', () => { + const first = resolveMothershipConversation(WORKSPACE_A, undefined) + const second = resolveMothershipConversation(WORKSPACE_A, ' ') + expect(isValidUuid(first.conversationId)).toBe(true) + expect(isValidUuid(first.chatId)).toBe(true) + expect(first.chatId).not.toBe(first.conversationId) + expect(first.chatId).not.toBe(second.chatId) + }) + + it('derives a stable chat id scoped to the workspace and exposes the given id', () => { + const resolved = resolveMothershipConversation(WORKSPACE_A, ' customer-456 ') + expect(resolved.conversationId).toBe('customer-456') + expect(isValidUuid(resolved.chatId)).toBe(true) + expect(resolveMothershipConversation(WORKSPACE_A, 'customer-456').chatId).toBe(resolved.chatId) + expect(resolveMothershipConversation(WORKSPACE_A, 'customer-457').chatId).not.toBe( + resolved.chatId + ) + expect(resolveMothershipConversation(WORKSPACE_B, 'customer-456').chatId).not.toBe( + resolved.chatId + ) + }) + + it('derives UUID-shaped ids too, so a block can never reach a chat it did not derive', () => { + const pasted = '3b2f0d4e-8a6c-4f1b-9e2d-5c7a1b3d9f00' + const resolved = resolveMothershipConversation(WORKSPACE_A, pasted) + expect(resolved.conversationId).toBe(pasted) + expect(resolved.chatId).not.toBe(pasted) + expect(isValidUuid(resolved.chatId)).toBe(true) + }) + + it('continues the same thread when the exposed id is chained into another block', () => { + const first = resolveMothershipConversation(WORKSPACE_A, undefined) + const chained = resolveMothershipConversation(WORKSPACE_A, first.conversationId) + expect(chained.chatId).toBe(first.chatId) + }) + + it('never forwards the literal string', () => { + const secretish = 'chat-plaintext-secret-__var_API_KEY-__sim_secret_API_KEY' + const { chatId } = resolveMothershipConversation(WORKSPACE_A, secretish) + expect(chatId).not.toContain('secret') + expect(chatId).not.toContain('__') + }) + + it('refuses to resolve without a workspace', () => { + expect(() => resolveMothershipConversation('', 'customer-456')).toThrow( + 'Workspace context is required' + ) + }) +}) diff --git a/apps/sim/lib/mothership/conversation-id.ts b/apps/sim/lib/mothership/conversation-id.ts new file mode 100644 index 00000000000..47224e109bf --- /dev/null +++ b/apps/sim/lib/mothership/conversation-id.ts @@ -0,0 +1,45 @@ +import { generateId } from '@sim/utils/id' +import { uuidV5 } from '@/lib/core/utils/uuid-v5' + +/** + * Namespace for chat ids derived from block conversation ids. Changing it + * re-keys every derived conversation, so every running block thread would + * lose its history. It must never change. + */ +const MOTHERSHIP_CONVERSATION_NAMESPACE = '2b7c5e0a-4f61-5d3e-9a8b-7c1d0e2f3a45' + +export interface ResolvedMothershipConversation { + /** The id the block exposes; feeding it to another Sim block continues the thread. */ + conversationId: string + /** The chat id sent over the wire, derived from the workspace and the conversation id. */ + chatId: string +} + +/** + * Resolves the chat id a Sim block conversation runs under. + * + * Builders may choose any stable string such as `customer-456` so a workflow + * continues one thread per customer, and chat ids are UUIDs everywhere + * downstream (`copilot_chats.id`, `workspace_files.chat_id`, and the copilot + * service's own conversation store). Every conversation id, UUID-shaped or not, + * is mapped to a UUID derived from the workspace and the id: the same value + * keeps the same thread across runs, two workspaces choosing the same value + * never share a conversation, a block can never reach a chat it did not derive + * (the copilot store has no ownership check of its own), and the literal value + * (which may carry a resolved secret) never leaves the executor. An omitted id + * mints a fresh token so the exposed id still continues the thread when chained. + */ +export function resolveMothershipConversation( + workspaceId: string, + conversationId: unknown +): ResolvedMothershipConversation { + if (!workspaceId) { + throw new Error('Workspace context is required to resolve a Sim conversation') + } + const provided = typeof conversationId === 'string' ? conversationId.trim() : '' + const token = provided || generateId() + return { + conversationId: token, + chatId: uuidV5(`${workspaceId}:${token}`, MOTHERSHIP_CONVERSATION_NAMESPACE), + } +} diff --git a/apps/sim/lib/oauth/github-installation.test.ts b/apps/sim/lib/oauth/github-installation.test.ts index 4267804d545..c49835e6514 100644 --- a/apps/sim/lib/oauth/github-installation.test.ts +++ b/apps/sim/lib/oauth/github-installation.test.ts @@ -6,6 +6,7 @@ import { assertGitHubInstallationActive, assertGitHubInstallationRepositoryActive, getGitHubInstallationConfiguration, + listGitHubInstallationRepositories, listUserAdminGitHubInstallations, parseGitHubInstallationBinding, resolveGitHubInstallationAccessToken, @@ -91,6 +92,92 @@ function mockDiscovery( } describe('GitHub installation setup', () => { + it('reports the failing token operation without including provider response contents', async () => { + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce(json({ message: 'sensitive provider detail' }, 422)) + await expect( + resolveGitHubInstallationAccessToken(binding, { repositoryId: '101' }) + ).rejects.toMatchObject({ + operation: 'repository-token', + status: 422, + message: + 'Check that the repository is included in the selected GitHub App installation, then retry.', + }) + }) + it('lists one metadata-only repository page with continuation and account checks', async () => { + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce(json(tokenResponse(101, false))) + .mockResolvedValueOnce( + json({ + total_count: 201, + repositories: Array.from({ length: 100 }, (_, i) => ({ + id: 101 + i, + full_name: `team/repo-${i}`, + owner: { id: 11 }, + default_branch: 'main', + })), + }) + ) + const controller = new AbortController() + const result = await listGitHubInstallationRepositories(binding, { + page: 2, + signal: controller.signal, + }) + expect(result.repositories).toHaveLength(100) + expect(result.repositories[0]).toEqual({ id: '101', fullName: 'team/repo-0' }) + expect(result.hasMore).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + permissions: { metadata: 'read' }, + }) + expect(fetchMock.mock.calls[2][0]).toBe( + 'https://api.github.com/installation/repositories?per_page=100&page=2' + ) + controller.abort() + expect(fetchMock.mock.calls[2][1]?.signal?.aborted).toBe(true) + }) + + it.each([0, 101, 1.5, Number.NaN])( + 'rejects an invalid repository page %s before provider reads', + async (page) => { + await expect(listGitHubInstallationRepositories(binding, { page })).rejects.toThrow( + 'page is invalid' + ) + expect(fetchMock).not.toHaveBeenCalled() + } + ) + + it('refuses a suspended installation before minting a listing token', async () => { + fetchMock.mockResolvedValueOnce(json({ ...installation, suspended_at: '2026-01-01T00:00:00Z' })) + await expect(listGitHubInstallationRepositories(binding)).rejects.toThrow('unavailable') + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('rejects content permissions in repository browsing tokens', async () => { + fetchMock.mockResolvedValueOnce(json(installation)).mockResolvedValueOnce(json(tokenResponse())) + await expect(listGitHubInstallationRepositories(binding)).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('rejects repositories from another installation account', async () => { + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce(json(tokenResponse(101, false))) + .mockResolvedValueOnce( + json({ + total_count: 1, + repositories: [ + { id: 101, full_name: 'other/repo', owner: { id: 12 }, default_branch: 'main' }, + ], + }) + ) + await expect(listGitHubInstallationRepositories(binding)).rejects.toThrow( + 'another GitHub installation account' + ) + }) + it('rechecks repository installation selection without caching a previous success', async () => { fetchMock .mockResolvedValueOnce(json(installation)) diff --git a/apps/sim/lib/oauth/github-installation.ts b/apps/sim/lib/oauth/github-installation.ts index 1e3c7dfe720..270ba839567 100644 --- a/apps/sim/lib/oauth/github-installation.ts +++ b/apps/sim/lib/oauth/github-installation.ts @@ -64,13 +64,24 @@ const repositorySchema = z.object({ export class GitHubInstallationError extends Error { constructor( message: string, - readonly status?: number + readonly status?: number, + readonly operation?: GitHubInstallationOperation ) { super(message) this.name = 'GitHubInstallationError' } } +type GitHubInstallationOperation = + | 'user' + | 'memberships' + | 'installations' + | 'installation' + | 'repository' + | 'repository-list' + | 'repository-token' + | 'listing-token' + function readConfiguration() { const appId = env.GITHUB_APP_ID?.trim() const clientId = env.GITHUB_APP_CLIENT_ID?.trim() @@ -143,7 +154,8 @@ async function request( path: string, token: string, signal: AbortSignal, - body?: unknown + body?: unknown, + operation: GitHubInstallationOperation = 'installation' ): Promise { const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) const response = await fetch(`${API_URL}${path}`, { @@ -162,8 +174,11 @@ async function request( if (!response.ok) { await response.body?.cancel() throw new GitHubInstallationError( - `GitHub installation request failed with HTTP ${response.status}`, - response.status + operation === 'repository-token' && response.status === 422 + ? 'Check that the repository is included in the selected GitHub App installation, then retry.' + : `GitHub ${operation} request failed with HTTP ${response.status}`, + response.status, + operation ) } return readResponseJsonWithLimit(response, { @@ -214,7 +229,7 @@ async function adminAccountIds(userAccessToken: string, signal: AbortSignal) { throw new GitHubInstallationError('Connect your GitHub account before choosing an installation') const user = z .object({ id: apiIdSchema, type: z.literal('User') }) - .parse(await request('/user', userAccessToken, signal)) + .parse(await request('/user', userAccessToken, signal, undefined, 'user')) const organizations = new Set() const membershipsSchema = z .array( @@ -231,7 +246,9 @@ async function adminAccountIds(userAccessToken: string, signal: AbortSignal) { await request( `/user/memberships/orgs?state=active&per_page=${PAGE_SIZE}&page=${page}`, userAccessToken, - signal + signal, + undefined, + 'memberships' ) ) for (const membership of memberships) { @@ -271,7 +288,9 @@ export async function listUserAdminGitHubInstallations( await request( `/user/installations?per_page=${PAGE_SIZE}&page=${page}`, userAccessToken, - signal + signal, + undefined, + 'installations' ) ) for (const installation of data.installations) { @@ -407,7 +426,8 @@ async function mintToken( ...(repositoryId ? { repository_ids: [Number(repositoryId)] } : { repositories: [repositoryName] }), - } + }, + 'repository-token' ) ) const expiresAt = Date.parse(response.expires_at) @@ -449,7 +469,13 @@ export async function resolveGitHubInstallationRepository( await assertGitHubInstallationActive(binding, { signal }) const token = await mintToken(binding, signal, undefined, repo) const resolved = repositorySchema.parse( - await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, token, signal) + await request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + token, + signal, + undefined, + 'repository' + ) ) if (String(resolved.owner.id) !== binding.accountId) throw new GitHubInstallationError('Repository belongs to another GitHub installation account') @@ -460,6 +486,60 @@ export async function resolveGitHubInstallationRepository( } } +/** Browses one bounded page with metadata-only access; content tokens remain repository-scoped. */ +export async function listGitHubInstallationRepositories( + binding: GitHubInstallationBinding, + options: RequestOptions & { page?: number } = {} +) { + const page = options.page ?? 1 + if (!Number.isSafeInteger(page) || page < 1 || page > 100) + throw new GitHubInstallationError('GitHub repository page is invalid') + const signal = operationSignal(options) + await assertGitHubInstallationActive(binding, { signal }) + const configuration = requireConfiguration() + const token = z + .object({ + token: z.string().min(1).max(1024), + expires_at: z.iso.datetime(), + permissions: z.object({ metadata: z.literal('read') }).strict(), + }) + .parse( + await request( + `/app/installations/${binding.installationId}/access_tokens`, + createAppJwt(configuration), + signal, + { permissions: { metadata: 'read' } }, + 'listing-token' + ) + ) + const expiresAt = Date.parse(token.expires_at) + if (expiresAt <= Date.now() || expiresAt > Date.now() + 65 * 60_000) + throw new GitHubInstallationError('GitHub returned an invalid installation token expiration') + const result = z + .object({ + total_count: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + repositories: z.array(repositorySchema).max(PAGE_SIZE), + }) + .parse( + await request( + `/installation/repositories?per_page=${PAGE_SIZE}&page=${page}`, + token.token, + signal, + undefined, + 'repository-list' + ) + ) + if (result.repositories.some((repository) => String(repository.owner.id) !== binding.accountId)) + throw new GitHubInstallationError('Repository belongs to another GitHub installation account') + return { + repositories: result.repositories.map((repository) => ({ + id: String(repository.id), + fullName: repository.full_name, + })), + hasMore: result.repositories.length === PAGE_SIZE && page * PAGE_SIZE < result.total_count, + } +} + /** Mints contents access for exactly one source repository; generic unscoped token reads are refused. */ export async function resolveGitHubInstallationAccessToken( binding: GitHubInstallationBinding, diff --git a/apps/sim/lib/oauth/github-repositories.test.ts b/apps/sim/lib/oauth/github-repositories.test.ts index 610e7006877..c2820879cc1 100644 --- a/apps/sim/lib/oauth/github-repositories.test.ts +++ b/apps/sim/lib/oauth/github-repositories.test.ts @@ -8,6 +8,7 @@ import { parseGitHubRepositoriesTokenResponse, verifyGitHubRepositoriesIdentity, } from '@/lib/oauth/github-repositories' +import { OAuthIdentityVerificationError } from '@/lib/oauth/identity-error' import { refreshOAuthToken } from '@/lib/oauth/oauth' const tokenResponse = { @@ -188,27 +189,144 @@ describe('GitHub identity verification', () => { ) }) - it.each([[primary], [{ ...work, verified: false }], []])( - 'refuses absent or unverified invited email %j', - async (emails) => { + it.each([{ emails: [primary] }, { emails: [{ ...work, verified: false }] }, { emails: [] }])( + 'refuses absent or unverified invited email $emails', + async ({ emails }) => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValueOnce(response(user)).mockResolvedValueOnce(response(emails)) ) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toThrow() + await expect( + verifyGitHubRepositoriesIdentity('ghu_access', work.email) + ).rejects.toMatchObject({ + name: 'OAuthIdentityVerificationError', + reason: 'email_mismatch', + stage: 'emails', + }) } ) it('rejects a bot identity', async () => { const fetchMock = vi.fn().mockResolvedValue(response({ ...user, type: 'Bot' })) vi.stubGlobal('fetch', fetchMock) - await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toThrow() + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ + reason: 'invalid_response', + stage: 'profile', + }) expect(fetchMock).toHaveBeenCalledTimes(1) }) it.each([401, 403])('fails closed when GitHub rejects identity with %i', async (status) => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({}, status))) - await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toThrow(`HTTP ${status}`) + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ + reason: 'provider_rejected', + stage: 'profile', + httpStatus: status, + }) + }) + + it('distinguishes denied email-read permission from a verified email mismatch', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(response(user)) + .mockResolvedValueOnce(response({ message: 'Resource not accessible by integration' }, 403)) + ) + await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + reason: 'email_access_denied', + stage: 'emails', + httpStatus: 403, + }) + }) + + it.each([ + { status: 429, headers: {}, message: 'Too many requests' }, + { status: 403, headers: { 'x-ratelimit-remaining': '0' }, message: 'Forbidden' }, + { status: 403, headers: { 'retry-after': '60' }, message: 'Forbidden' }, + { status: 403, headers: {}, message: 'You have exceeded a secondary rate limit.' }, + { status: 403, headers: {}, message: 'You have triggered an abuse detection mechanism.' }, + ])( + 'does not misdiagnose provider rate limiting as missing email permission: %j', + async (error) => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(response(user)) + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: error.message }), { + status: error.status, + headers: error.headers, + }) + ) + ) + await expect( + verifyGitHubRepositoriesIdentity('ghu_access', work.email) + ).rejects.toMatchObject({ + reason: 'rate_limited', + stage: 'emails', + httpStatus: error.status, + }) + } + ) + + it('reports GitHub service failure without retaining its response body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ message: work.email }, 503))) + const failure = await verifyGitHubRepositoriesIdentity('ghu_access', work.email).catch( + (error: unknown) => error + ) + expect(failure).toBeInstanceOf(OAuthIdentityVerificationError) + expect(failure).toMatchObject({ + reason: 'provider_unavailable', + stage: 'profile', + httpStatus: 503, + }) + expect(JSON.stringify(failure)).not.toContain(work.email) + }) + + it('sanitizes network failures instead of retaining a transport error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('transport failed ghu_access'))) + const failure = await verifyGitHubRepositoriesIdentity('ghu_access', work.email).catch( + (error: unknown) => error + ) + expect(failure).toMatchObject({ reason: 'provider_unavailable', stage: 'profile' }) + expect(String(failure)).not.toContain('ghu_access') + expect(failure).not.toHaveProperty('cause') + }) + + it('distinguishes an invalid email response from a verified email mismatch', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(response(user)) + .mockResolvedValueOnce( + response([ + { + email: work.email, + primary: false, + }, + ]) + ) + ) + await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + reason: 'invalid_response', + stage: 'emails', + }) + }) + + it('does not claim a mismatch when the bounded email scan cannot finish', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(response(user)) + for (let page = 0; page < 10; page++) { + fetchMock.mockResolvedValueOnce(response(Array.from({ length: 100 }, () => primary))) + } + vi.stubGlobal('fetch', fetchMock) + await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + reason: 'invalid_response', + stage: 'emails', + }) + expect(fetchMock).toHaveBeenCalledTimes(11) }) }) diff --git a/apps/sim/lib/oauth/github-repositories.ts b/apps/sim/lib/oauth/github-repositories.ts index 5c3ce80c12c..93c9a6361e5 100644 --- a/apps/sim/lib/oauth/github-repositories.ts +++ b/apps/sim/lib/oauth/github-repositories.ts @@ -3,6 +3,10 @@ import { getOAuth2Tokens, type OAuth2Tokens } from 'better-auth/oauth2' import type { GenericOAuthConfig } from 'better-auth/plugins' import { z } from 'zod' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { + OAuthIdentityVerificationError, + type OAuthIdentityVerificationStage, +} from '@/lib/oauth/identity-error' export const GITHUB_REPOSITORIES_PROVIDER_ID = 'github-repositories' export const GITHUB_AUTHORIZATION_URL = 'https://github.com/login/oauth/authorize' @@ -47,7 +51,7 @@ export async function verifyGitHubRepositoriesIdentity( expectedEmail?: string ) { if (!accessToken.startsWith('ghu_')) { - throw new Error('Connect with a GitHub App user authorization') + throw new OAuthIdentityVerificationError('provider_rejected', 'token') } const headers = { Accept: 'application/vnd.github+json', @@ -55,29 +59,76 @@ export async function verifyGitHubRepositoriesIdentity( 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'Sim', } - async function get(path: string): Promise { + async function get(path: string, stage: OAuthIdentityVerificationStage): Promise { const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS) - const response = await fetch(`${API_BASE_URL}${path}`, { - headers, - signal, - redirect: 'error', - }) + let response: Response + try { + response = await fetch(`${API_BASE_URL}${path}`, { + headers, + signal, + redirect: 'error', + }) + } catch { + throw new OAuthIdentityVerificationError('provider_unavailable', stage) + } if (!response.ok) { - await response.body?.cancel() - throw new Error(`GitHub identity request failed with HTTP ${response.status}`) + let rateLimited = + response.status === 429 || + (response.status === 403 && + (response.headers.get('x-ratelimit-remaining') === '0' || + response.headers.has('retry-after'))) + if (response.status === 403 && !rateLimited) { + const body = await readResponseJsonWithLimit(response, { + maxBytes: RESPONSE_MAX_BYTES, + signal, + label: 'GitHub identity error response', + }).catch(() => null) + rateLimited = + typeof body === 'object' && + body !== null && + 'message' in body && + typeof body.message === 'string' && + /rate limit|abuse detection/i.test(body.message) + } else { + await response.body?.cancel().catch(() => undefined) + } + const reason = rateLimited + ? 'rate_limited' + : response.status >= 500 + ? 'provider_unavailable' + : response.status === 403 && stage === 'emails' + ? 'email_access_denied' + : 'provider_rejected' + throw new OAuthIdentityVerificationError(reason, stage, response.status) + } + try { + return await readResponseJsonWithLimit(response, { + maxBytes: RESPONSE_MAX_BYTES, + signal, + label: 'GitHub identity response', + }) + } catch { + throw new OAuthIdentityVerificationError( + signal.aborted ? 'provider_unavailable' : 'invalid_response', + stage, + response.status + ) } - return readResponseJsonWithLimit(response, { - maxBytes: RESPONSE_MAX_BYTES, - signal, - label: 'GitHub identity response', - }) } - const user = userSchema.parse(await get('/user')) + const parsedUser = userSchema.safeParse(await get('/user', 'profile')) + if (!parsedUser.success) { + throw new OAuthIdentityVerificationError('invalid_response', 'profile') + } + const user = parsedUser.data const normalizedEmail = expectedEmail?.trim().toLowerCase() for (let page = 1; page <= MAX_EMAIL_PAGES; page++) { - const emails = emailsSchema.parse( - await get(`/user/emails?per_page=${EMAIL_PAGE_SIZE}&page=${page}`) + const parsedEmails = emailsSchema.safeParse( + await get(`/user/emails?per_page=${EMAIL_PAGE_SIZE}&page=${page}`, 'emails') ) + if (!parsedEmails.success) { + throw new OAuthIdentityVerificationError('invalid_response', 'emails') + } + const emails = parsedEmails.data const matching = emails.find( (entry) => entry.verified && @@ -94,9 +145,11 @@ export async function verifyGitHubRepositoriesIdentity( grantedScopes: [], } } - if (emails.length < EMAIL_PAGE_SIZE) break + if (emails.length < EMAIL_PAGE_SIZE) { + throw new OAuthIdentityVerificationError('email_mismatch', 'emails') + } } - throw new Error('GitHub did not verify the required email address') + throw new OAuthIdentityVerificationError('invalid_response', 'emails') } interface GitHubRepositoriesProviderParams { diff --git a/apps/sim/lib/oauth/identity-error.ts b/apps/sim/lib/oauth/identity-error.ts new file mode 100644 index 00000000000..8fbac5f2a1c --- /dev/null +++ b/apps/sim/lib/oauth/identity-error.ts @@ -0,0 +1,21 @@ +export type OAuthIdentityFailureReason = + | 'email_mismatch' + | 'email_access_denied' + | 'provider_rejected' + | 'rate_limited' + | 'provider_unavailable' + | 'invalid_response' + +export type OAuthIdentityVerificationStage = 'token' | 'profile' | 'emails' + +/** Carries safe diagnostics without retaining provider bodies, tokens, or email addresses. */ +export class OAuthIdentityVerificationError extends Error { + constructor( + readonly reason: OAuthIdentityFailureReason, + readonly stage: OAuthIdentityVerificationStage, + readonly httpStatus?: number + ) { + super(`OAuth identity verification failed: ${reason}`) + this.name = 'OAuthIdentityVerificationError' + } +} diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d2bc4cacce3..b97bc4fd9e8 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -33,6 +33,7 @@ export const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' as const export type OAuthProvider = | 'github-repositories' + | 'github-app-installation' | 'google' | 'google-email' | 'google-drive' diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index e9ccbf261f1..1b964c1ce0f 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -620,19 +620,13 @@ export interface PostHogEventMap { action_id?: string } - /** The chat composer's mode switcher picked a different mode. */ - chat_mode_changed: { - workspace_id: string - mode: 'build' | 'search' | 'assistant' - } - /** * A home-page suggested action was clicked. `action_id` is the candidate id - * (e.g. `gmail-0`); `connector` rows are the Search-mode "Connect X" rows. + * (e.g. `integrate-gmail`). */ suggested_action_clicked: { workspace_id: string - kind: 'prompt' | 'integration' | 'connector' + kind: 'prompt' | 'integration' action_id: string label: string position: number diff --git a/apps/sim/lib/posthog/server.test.ts b/apps/sim/lib/posthog/server.test.ts index ec472a5afcf..169f2e0c713 100644 --- a/apps/sim/lib/posthog/server.test.ts +++ b/apps/sim/lib/posthog/server.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import type { MockInstance } from 'vitest' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' @@ -31,6 +32,89 @@ describe('captureServerEvent', () => { beforeEach(() => { captureSpy.mockClear() captureSpy.mockImplementation(() => {}) + vi.mocked(loggerMock.getRequestContext).mockReturnValue(undefined) + }) + + it('stamps the resolved client from the request context onto every event', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ + requestId: 'req-1', + client: { surface: 'cli', version: '2.1.2', agent: 'claude-code', source: 'header' }, + }) + + captureServerEvent('user-1', 'workflow_deployed', { + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + }) + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + request_id: 'req-1', + surface: 'cli', + client_version: '2.1.2', + coding_agent: 'claude-code', + }), + }) + ) + }) + + it('stamps the request, its authentication, and the workflow call chain', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ + requestId: 'req-1', + method: 'POST', + path: '/api/v2/workflows/wf-3/execute', + auth: { kind: 'oauth_access_token', clientId: 'sim-cli' }, + callChain: ['wf-1', 'wf-2'], + }) + + captureServerEvent('user-1', 'workflow_deployed', { + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + }) + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + api_method: 'POST', + api_path: '/api/v2/workflows/wf-3/execute', + auth_kind: 'oauth_access_token', + auth_client_id: 'sim-cli', + call_chain_depth: 2, + call_chain_root_workflow_id: 'wf-1', + caller_workflow_id: 'wf-2', + }), + }) + ) + }) + + it('leaves out what the request did not establish', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ requestId: 'req-1' }) + + captureServerEvent('user-1', 'workflow_deployed', { + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + }) + + expect(captureSpy.mock.calls[0][0].properties).toEqual({ + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + request_id: 'req-1', + }) + }) + + it('never overwrites attribution a caller set explicitly', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ + requestId: 'req-1', + client: { surface: 'web', source: 'fetch_metadata' }, + }) + + captureServerEvent('user-1', 'search_result_selected', { + surface: 'copilot', + } as never) + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ properties: expect.objectContaining({ surface: 'copilot' }) }) + ) }) it('swallows a failing client instead of propagating to the caller', () => { diff --git a/apps/sim/lib/posthog/server.ts b/apps/sim/lib/posthog/server.ts index f2e5a828fa1..022c760990d 100644 --- a/apps/sim/lib/posthog/server.ts +++ b/apps/sim/lib/posthog/server.ts @@ -55,15 +55,52 @@ interface CaptureOptions { setOnce?: PersonProperties } +/** + * What the ambient request context contributes to every event: the request it + * happened in, which client the user was on (web, desktop, CLI, an SDK) and, + * for the CLI, which AI coding agent was driving it; how the request + * authenticated; and, when one workflow's run made the call, the chain of + * workflows behind it. Stamped here rather than at each of the many capture + * sites so no event can forget it, and only for properties the caller did not + * set itself. + */ +function contextProperties(explicit: Record): Record { + const context = getRequestContext() + if (!context) return {} + const merged: Record = {} + const stamp = (key: string, value: unknown) => { + if (value !== undefined && !(key in explicit)) merged[key] = value + } + + stamp('request_id', context.requestId) + stamp('api_method', context.method) + stamp('api_path', context.path) + + stamp('surface', context.client?.surface) + stamp('client_version', context.client?.version) + stamp('coding_agent', context.client?.agent) + + stamp('auth_kind', context.auth?.kind) + stamp('auth_service', context.auth?.service) + stamp('auth_client_id', context.auth?.clientId) + + const chain = context.callChain + if (chain && chain.length > 0) { + stamp('call_chain_depth', chain.length) + stamp('call_chain_root_workflow_id', chain[0]) + stamp('caller_workflow_id', chain[chain.length - 1]) + } + return merged +} + function buildCaptureProperties( properties: PostHogEventMap[E], options?: CaptureOptions ): Record { - const contextRequestId = getRequestContext()?.requestId const props = properties as Record return { ...properties, - ...(contextRequestId && !('request_id' in props) ? { request_id: contextRequestId } : {}), + ...contextProperties(props), ...(options?.insertId ? { $insert_id: options.insertId } : {}), ...(options?.groups ? { $groups: options.groups } : {}), ...(options?.set ? { $set: options.set } : {}), diff --git a/apps/sim/lib/posthog/surface.ts b/apps/sim/lib/posthog/surface.ts new file mode 100644 index 00000000000..139b10d7d4a --- /dev/null +++ b/apps/sim/lib/posthog/surface.ts @@ -0,0 +1,12 @@ +import { resolveBrowserSurface } from '@/lib/api/client-info' + +/** + * The super properties that attribute every browser-side event to the surface + * the user is on. Registered once at PostHog initialization so client events + * carry the same `surface` property the server stamps from `X-Sim-Client-Info`, + * and a single breakdown covers both. + */ +export function surfaceSuperProperties(): { surface: 'web' | 'desktop'; app_version?: string } { + const { surface, version } = resolveBrowserSurface() + return version === undefined ? { surface } : { surface, app_version: version } +} diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index 60f75064fa7..e0f2a9e0625 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -20,6 +20,15 @@ const mocks = vi.hoisted(() => ({ resolveReferences: vi.fn(), resolveScope: vi.fn(), sanitize: vi.fn(), + authorizePersonalSearch: vi.fn(), + requireOrganizationMembership: vi.fn(), +})) + +vi.mock('@/lib/knowledge/application/personal-search-account', () => ({ + authorizePersonalSearchSetup: mocks.authorizePersonalSearch, +})) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + requireOrganizationMembership: mocks.requireOrganizationMembership, })) vi.mock('@sim/audit', () => ({ recordAudit: vi.fn() })) @@ -157,6 +166,124 @@ describe('executeSelector', () => { ]) }) + it.each(['gmail.labels', 'github.installationRepositories'])( + 'keeps generic organization browsing admin-only for %s', + async (selectorKey) => { + mocks.requireOrganizationMembership.mockRejectedValueOnce(new Error('Admin required')) + await expect( + execute({ selectorKey, scope: { kind: 'organization', organizationId: 'org-1' } }) + ).rejects.toThrow('Admin required') + expect(mocks.requireOrganizationMembership).toHaveBeenCalledWith( + principal, + 'org-1', + 'admin', + 'knowledge.use' + ) + expect(mocks.authorizePersonalSearch).not.toHaveBeenCalled() + expect(mocks.getAttachment).not.toHaveBeenCalled() + } + ) + + it('refuses organization GitHub installation browsing from workspace scope before resolving credentials', async () => { + mocks.resolveScope.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + selectorKey: 'github.installationRepositories', + selectorManifest: getSelectorManifestEntry('github.installationRepositories'), + selectorScope: scope, + }) + await expect(execute({ selectorKey: 'github.installationRepositories' })).rejects.toThrow( + 'Context unavailable' + ) + expect(mocks.authorizeCredential).not.toHaveBeenCalled() + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + it('rejects a personal setup marker outside its approved provider selector and organization scope', async () => { + await expect(execute({ personalSearchSetup: 'jira' })).rejects.toBeInstanceOf( + SelectorContextUnavailableError + ) + await expect( + execute({ + scope: { kind: 'organization', organizationId: 'org-1' }, + selectorKey: 'jira.issues', + personalSearchSetup: 'jira', + }) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + expect(mocks.authorizeCredential).not.toHaveBeenCalled() + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + it('requires the personal setup authorization before canonical discovery and provider calls', async () => { + mocks.authorizePersonalSearch.mockRejectedValueOnce(new Error('Integration unapproved')) + await expect( + execute({ + scope: { kind: 'organization', organizationId: 'org-1' }, + selectorKey: 'jira.projectKeys', + context: { oauthCredential: 'managed-1', domain: 'example.atlassian.net' }, + personalSearchSetup: 'jira', + }) + ).rejects.toThrow('Integration unapproved') + expect(mocks.authorizePersonalSearch).toHaveBeenCalledWith(principal, { + organizationId: 'org-1', + connectorType: 'jira', + }) + expect(mocks.resolveScope).not.toHaveBeenCalled() + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + it('uses the shared selector execution and records the prepared account access once', async () => { + const personalScope = { kind: 'organization' as const, organizationId: 'org-1' } + mocks.resolveScope.mockResolvedValueOnce({ + organizationId: 'org-1', + workspaceId: undefined, + selectorKey: 'jira.projectKeys', + selectorManifest: getSelectorManifestEntry('jira.projectKeys'), + selectorScope: personalScope, + }) + mocks.resolveReferences.mockResolvedValueOnce({ + context: { oauthCredential: 'managed-1', domain: 'example.atlassian.net' }, + request: { kind: 'list' }, + references: new Map(), + }) + mocks.authorizeCredential.mockResolvedValueOnce({ + suppliedId: 'managed-1', + providerId: 'jira', + personalSearchSetup: { principal, organizationId: 'org-1', connectorType: 'jira' }, + }) + mocks.getAttachment.mockReturnValueOnce({ + destination: 'fixed', + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['jira'] }, + auditCredentialUse: true, + execute: async (args: ExecuteServerSelectorArgs) => { + args.recordCredentialUse?.('jira') + args.recordCredentialUse?.('jira') + return { kind: 'list', items: [{ id: 'PROJECT', label: 'Project' }] } + }, + }) + await expect( + execute({ + selectorKey: 'jira.projectKeys', + scope: personalScope, + context: { oauthCredential: 'managed-1', domain: 'example.atlassian.net' }, + personalSearchSetup: 'jira', + }) + ).resolves.toEqual({ kind: 'list', items: [{ id: 'PROJECT', label: 'Project' }] }) + expect(mocks.authorizeCredential).toHaveBeenCalledWith( + expect.objectContaining({ personalSearchSetup: 'jira', organizationId: 'org-1' }) + ) + expect(mocks.requireOrganizationMembership).not.toHaveBeenCalled() + expect(mocks.recordCredentialAccess).toHaveBeenCalledTimes(1) + expect(mocks.recordCredentialAccess).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: principal.userId, + resourceId: 'managed-1', + providerId: 'jira', + }) + ) + }) + /** * The picker is a use of the integration, not a neutral list: it reaches the * provider's API with the caller's credential. The authorization funnel never diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 1f70f08cc9f..32ca55fb3b5 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -8,6 +8,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import type { OperationUseCase } from '@/lib/core/application/operation' import { requireOrganizationMembership } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizePersonalSearchSetup } from '@/lib/knowledge/application/personal-search-account' import { type CredentialAuditRequest, recordCredentialAccess } from '@/lib/oauth/token-resolution' import { selectorOperations } from '@/lib/selectors/application/operations' import { @@ -39,6 +40,8 @@ const logger = createLogger('ExecuteSelector') export interface ExecuteSelectorInput extends ExecuteSelectorRequest { signal?: AbortSignal auditRequest?: CredentialAuditRequest + /** Set only by the personal Search setup use case; excluded from the public selector contract. */ + personalSearchSetup?: 'jira' | 'confluence' } function validateAuthorizedInput( @@ -164,6 +167,7 @@ async function executeAuthorizedSelector(args: { scope: args.input.scope, workspaceId: args.context.workspaceId, organizationId, + personalSearchSetup: args.input.personalSearchSetup, policy: attachment.credential, protectedValues, references: resolved.references, @@ -194,19 +198,24 @@ async function executeAuthorizedSelector(args: { }) const credentialAccess = credential?.access + const credentialResourceId = credential?.personalSearchSetup + ? credential.suppliedId + : credentialAccess?.resolvedCredentialId let credentialUseRecorded = false const recordCredentialUse = - attachment.auditCredentialUse && credentialAccess?.resolvedCredentialId + attachment.auditCredentialUse && credentialResourceId ? (providerId: string) => { if (credentialUseRecorded) return credentialUseRecorded = true recordCredentialAccess({ actorId: args.principal.userId, workspaceId: args.context.workspaceId ?? null, - resourceId: credentialAccess.resolvedCredentialId!, + resourceId: credentialResourceId, providerId: credential?.providerId ?? providerId, credentialType: - credentialAccess.credentialType === 'service_account' ? 'service_account' : 'oauth', + credentialAccess?.credentialType === 'service_account' + ? 'service_account' + : 'oauth', auditRequest: args.input.auditRequest, }) } @@ -337,14 +346,26 @@ export const executeSelector: OperationUseCase< auditRequest: args.input.auditRequest ?? args.request, }, } - if (args.input.scope.kind !== 'organization') return executeWorkspaceSelector.execute(args) + if (args.input.scope.kind !== 'organization') { + if (args.input.personalSearchSetup) throw new SelectorContextUnavailableError() + return executeWorkspaceSelector.execute(args) + } if (args.principal.kind !== 'session') throw new SelectorContextUnavailableError() - await requireOrganizationMembership( - args.principal, - args.input.scope.organizationId, - 'admin', - 'knowledge.use' - ) + if (args.input.personalSearchSetup) { + const selectorKey = + args.input.personalSearchSetup === 'jira' ? 'jira.projectKeys' : 'confluence.spaces' + if (args.input.selectorKey !== selectorKey) throw new SelectorContextUnavailableError() + await authorizePersonalSearchSetup(args.principal, { + organizationId: args.input.scope.organizationId, + connectorType: args.input.personalSearchSetup, + }) + } else + await requireOrganizationMembership( + args.principal, + args.input.scope.organizationId, + 'admin', + 'knowledge.use' + ) const context = await resolveSelectorApplicationContext({ selectorKey: args.input.selectorKey as ServerSelectorKey, scope: args.input.scope, diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index e9cb015bf5f..f8a41558c4b 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -9,8 +9,8 @@ describe('selector manifest', () => { const count = (classification: (typeof classifications)[number]) => classifications.filter((value) => value === classification).length - expect(Object.keys(selectorManifest)).toHaveLength(97) - expect(count('provider-server')).toBe(84) + expect(Object.keys(selectorManifest)).toHaveLength(98) + expect(count('provider-server')).toBe(85) expect(count('internal-server')).toBe(12) expect(count('local')).toBe(1) expect(classifications).not.toContain('provider-legacy') @@ -36,7 +36,7 @@ describe('selector manifest', () => { const rawConnectionKeys = providerKeys.filter( (key) => !serverSelectorRegistry[key as keyof typeof serverSelectorRegistry].credential ) - expect(providerKeys).toHaveLength(84) + expect(providerKeys).toHaveLength(85) expect(rawConnectionKeys.sort()).toEqual([ 'cloudwatch.logGroups', 'cloudwatch.logStreams', diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index b6596873305..6d65ad50ea5 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -147,6 +147,10 @@ export const selectorManifest = { detail: true, }), 'gmail.labels': providerSelector(['impersonateUserEmail']), + 'github.installationRepositories': { + ...providerSelector([], { listMode: 'paginated', detail: true, unknownDetail: true }), + scopeKinds: ['organization'], + }, 'google.calendar': providerSelector(['impersonateUserEmail'], { listMode: 'paginated', detail: true, diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 1276041dfa8..f895d933793 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ credentialProviderMatchesService: vi.fn(), getServiceConfig: vi.fn(), resolveCredentialTokenBundle: vi.fn(), + authorizePersonalSearch: vi.fn(), + resolveManagedOAuthToken: vi.fn(), })) vi.mock('@/lib/auth/credential-access', () => ({ @@ -28,6 +30,13 @@ vi.mock('@/lib/oauth/credential-service', () => ({ resolveCredentialTokenBundle: mocks.resolveCredentialTokenBundle, })) +vi.mock('@/lib/knowledge/application/personal-search-account', () => ({ + authorizePersonalSearchSetupCredential: mocks.authorizePersonalSearch, +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ + resolveManagedOAuthToken: mocks.resolveManagedOAuthToken, +})) + vi.mock('@/lib/oauth/utils', () => ({ credentialProviderMatchesService: mocks.credentialProviderMatchesService, getServiceConfigByServiceId: mocks.getServiceConfig, @@ -117,6 +126,83 @@ describe('authorizeSelectorCredential', () => { }) }) + it('browses with only the member’s own prepared account and refreshes it without the admin credential path', async () => { + mocks.authorizePersonalSearch.mockResolvedValue({ id: 'managed-1', providerId: 'jira' }) + mocks.resolveManagedOAuthToken.mockResolvedValue({ accessToken: 'private-token' }) + const selected = await authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'managed-1' }, + scope: { kind: 'organization', organizationId: 'org-1' }, + organizationId: 'org-1', + personalSearchSetup: 'jira', + policy: { kind: 'stored', field: 'oauthCredential', serviceIds: ['jira'] }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + const protectedValues = createSelectorProtectedValues() + const recordCredentialUse = vi.fn() + await expect( + resolveSelectorOAuthAccessToken({ + credential: selected, + serviceId: 'jira', + scopes: ['read:jira-work'], + protectedValues, + recordCredentialUse, + }) + ).resolves.toBe('private-token') + expect(mocks.authorizePersonalSearch).toHaveBeenCalledTimes(2) + expect(mocks.resolveManagedOAuthToken).toHaveBeenCalledWith({ + organizationId: 'org-1', + credentialId: 'managed-1', + expectedProviderId: 'jira', + requiredScopes: ['read:jira-work'], + }) + expect(protectedValues.contains('private-token')).toBe(true) + expect(recordCredentialUse).toHaveBeenCalledWith('jira') + expect(mocks.authorizeOrganizationCredentialUse).not.toHaveBeenCalled() + expect(mocks.resolveOrganizationCredentialTokenBundle).not.toHaveBeenCalled() + }) + + it('rejects a revoked prepared credential before refreshing its token', async () => { + mocks.authorizePersonalSearch.mockRejectedValue(new Error('Credential revoked')) + await expect( + resolveSelectorOAuthAccessToken({ + credential: { + suppliedId: 'managed-1', + providerId: 'jira', + personalSearchSetup: { principal, organizationId: 'org-1', connectorType: 'jira' }, + }, + serviceId: 'jira', + protectedValues: createSelectorProtectedValues(), + }) + ).rejects.toThrow('Credential revoked') + expect(mocks.resolveManagedOAuthToken).not.toHaveBeenCalled() + }) + + it('refuses using a prepared Jira credential for another provider or impersonation', async () => { + const selected = { + suppliedId: 'managed-1', + providerId: 'jira', + personalSearchSetup: { principal, organizationId: 'org-1', connectorType: 'jira' as const }, + } + await expect( + resolveSelectorOAuthAccessToken({ + credential: selected, + serviceId: 'confluence', + protectedValues: createSelectorProtectedValues(), + }) + ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) + await expect( + resolveSelectorOAuthAccessToken({ + credential: selected, + serviceId: 'jira', + impersonateEmail: 'other@example.com', + protectedValues: createSelectorProtectedValues(), + }) + ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) + expect(mocks.resolveManagedOAuthToken).not.toHaveBeenCalled() + }) + it('rejects an organization connection for the wrong selector provider', async () => { mocks.authorizeOrganizationCredentialUse.mockResolvedValue({ credential: { id: 'managed-1', providerId: 'jira', type: 'managed_oauth' }, diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 83a152cb560..82f1d9a7bcc 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -9,6 +9,8 @@ import { authorizeOrganizationCredentialUse, resolveOrganizationCredentialTokenBundle, } from '@/lib/credentials/application/organization-credentials' +import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' +import { authorizePersonalSearchSetupCredential } from '@/lib/knowledge/application/personal-search-account' import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' import { credentialProviderMatchesService, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' @@ -104,6 +106,7 @@ export async function authorizeSelectorCredential(input: { scope: SelectorScope workspaceId?: string organizationId?: string + personalSearchSetup?: 'jira' | 'confluence' policy: SelectorCredentialPolicy protectedValues: SelectorProtectedValues references: ReadonlyMap @@ -111,6 +114,32 @@ export async function authorizeSelectorCredential(input: { const suppliedId = input.context[input.policy.field] if (!suppliedId) throw new SelectorConnectionUnavailableError() + if (input.personalSearchSetup) { + if ( + input.scope.kind !== 'organization' || + input.principal.kind !== 'session' || + input.organizationId !== input.scope.organizationId || + input.workspaceId || + !input.policy.serviceIds.includes(input.personalSearchSetup) + ) + throw new SelectorConnectionUnavailableError() + const row = await authorizePersonalSearchSetupCredential(input.principal, { + organizationId: input.scope.organizationId, + connectorType: input.personalSearchSetup, + credentialId: suppliedId, + }) + input.protectedValues.add(suppliedId, 'reference') + return { + suppliedId, + providerId: row.providerId, + personalSearchSetup: { + principal: input.principal, + organizationId: input.scope.organizationId, + connectorType: input.personalSearchSetup, + }, + } + } + if (input.scope.kind === 'organization') { if ( input.principal.kind !== 'session' || @@ -187,6 +216,31 @@ export async function resolveSelectorOAuthAccessToken(input: { input.credential.signal?.throwIfAborted() if (input.credential.fixedToken) return input.credential.fixedToken + if (input.credential.personalSearchSetup) { + const setup = input.credential.personalSearchSetup + if (input.impersonateEmail || input.serviceId !== setup.connectorType) { + throw new SelectorConnectionUnavailableError() + } + await authorizePersonalSearchSetupCredential(setup.principal, { + ...setup, + credentialId: input.credential.suppliedId, + }) + const result = await waitForSelectorCredentialResolution( + resolveManagedOAuthToken({ + organizationId: setup.organizationId, + credentialId: input.credential.suppliedId, + expectedProviderId: setup.connectorType, + requiredScopes: input.scopes ? [...input.scopes] : [], + }), + input.credential.signal + ) + input.credential.signal?.throwIfAborted() + if (!result?.accessToken) throw new SelectorConnectionUnavailableError() + input.protectedValues.add(result.accessToken) + input.recordCredentialUse?.(setup.connectorType) + return result.accessToken + } + if (input.credential.organization) { const result = await waitForSelectorCredentialResolution( resolveOrganizationCredentialTokenBundle({ diff --git a/apps/sim/lib/selectors/server/providers/confluence.test.ts b/apps/sim/lib/selectors/server/providers/confluence.test.ts index a4437bb0b90..5faa48c5e68 100644 --- a/apps/sim/lib/selectors/server/providers/confluence.test.ts +++ b/apps/sim/lib/selectors/server/providers/confluence.test.ts @@ -134,23 +134,70 @@ describe('Confluence server selector adapters', () => { ) }) - it('hydrates a legacy numeric value in the key selector without rewriting it', async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ id: '12345', key: 'ENG', name: 'Engineering' }), { - status: 200, + it.each(['current', 'archived'] as const)( + 'looks up a numeric %s space key as a key rather than a resource ID', + async (status) => { + mockFetch.mockImplementation((input: URL) => { + const matches = new URL(input).searchParams.get('status') === status + return new Response( + JSON.stringify({ + results: matches ? [{ id: '99999', key: '12345', name: 'Numeric key' }] : [], + }), + { status: 200 } + ) }) - ) + await expect( + confluenceSelectorAttachments['confluence.spaces'].execute({ + ...spaceDetailArgs(), + request: { kind: 'detail', id: '12345' }, + }) + ).resolves.toEqual({ + kind: 'detail', + item: { + id: '12345', + label: status === 'archived' ? 'Numeric key (12345) — archived' : 'Numeric key (12345)', + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + for (const [input] of mockFetch.mock.calls) { + const url = new URL(String(input)) + expect(url.pathname).toBe('/ex/confluence/cloud-1/wiki/api/v2/spaces') + expect(url.searchParams.get('keys')).toBe('12345') + } + } + ) + + it('does not accept a numeric resource ID as a matching space key', async () => { + mockFetch.mockImplementation( + () => + new Response( + JSON.stringify({ results: [{ id: '12345', key: 'ENG', name: 'Engineering' }] }), + { status: 200 } + ) + ) await expect( confluenceSelectorAttachments['confluence.spaces'].execute({ ...spaceDetailArgs(), request: { kind: 'detail', id: '12345' }, }) - ).resolves.toEqual({ - kind: 'detail', - item: { id: '12345', label: 'Engineering (ENG)' }, - }) - expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/wiki/api/v2/spaces/12345') + ).resolves.toEqual({ kind: 'detail', item: null }) + }) + + it('preserves key aliases when hydrating the ID selector', async () => { + mockFetch.mockImplementation( + () => + new Response( + JSON.stringify({ results: [{ id: '12345', key: 'ENG', name: 'Engineering' }] }), + { status: 200 } + ) + ) + await expect( + confluenceSelectorAttachments['confluence.spacesById'].execute({ + ...spaceIdDetailArgs(), + request: { kind: 'detail', id: 'ENG' }, + }) + ).resolves.toEqual({ kind: 'detail', item: { id: 'ENG', label: 'Engineering (ENG)' } }) }) it('projects provider IDs for block space lists while key selectors remain unchanged', async () => { @@ -307,6 +354,87 @@ describe('Confluence server selector adapters', () => { ).rejects.toMatchObject({ name: 'SelectorConnectionUnavailableError', status: 403 }) }) + it.each([ + { failedStatus: 'current', status: 401 }, + { failedStatus: 'current', status: 403 }, + { failedStatus: 'current', status: 429 }, + { failedStatus: 'archived', status: 401 }, + { failedStatus: 'archived', status: 403 }, + { failedStatus: 'archived', status: 429 }, + ])( + 'preserves $status from the $failedStatus lookup when the other status has no matching space', + async ({ failedStatus, status }) => { + mockFetch.mockImplementation((input: URL) => + new URL(input).searchParams.get('status') === failedStatus + ? new Response(null, { status }) + : Response.json({ results: [{ id: '99999', key: 'OTHER', name: 'Other space' }] }) + ) + + await expect( + confluenceSelectorAttachments['confluence.spaces'].execute(spaceDetailArgs()) + ).rejects.toMatchObject({ + name: + status === 429 ? 'SelectorOptionsUnavailableError' : 'SelectorConnectionUnavailableError', + status, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + } + ) + + it.each(['current', 'archived'] as const)( + 'returns an exact %s match even when the other status lookup fails', + async (matchingStatus) => { + mockFetch.mockImplementation((input: URL) => + new URL(input).searchParams.get('status') === matchingStatus + ? Response.json({ results: [{ id: '12345', key: 'ENG', name: 'Engineering' }] }) + : new Response(null, { status: 429 }) + ) + + await expect( + confluenceSelectorAttachments['confluence.spaces'].execute(spaceDetailArgs()) + ).resolves.toEqual({ + kind: 'detail', + item: { + id: 'ENG', + label: + matchingStatus === 'archived' ? 'Engineering (ENG) — archived' : 'Engineering (ENG)', + }, + }) + } + ) + + it('reports a space key missing only when both status lookups succeed without a match', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ results: [{ id: '99999', key: 'OTHER', name: 'Other space' }] }) + ) + .mockResolvedValueOnce(Response.json({ results: [] })) + + await expect( + confluenceSelectorAttachments['confluence.spaces'].execute(spaceDetailArgs()) + ).resolves.toEqual({ kind: 'detail', item: null }) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('sanitizes an arbitrary partial lookup failure rather than reporting the key missing', async () => { + const fetchProviderJson = vi + .spyOn(providerHttp, 'fetchProviderJson') + .mockRejectedValueOnce(new Error('raw provider failure')) + .mockResolvedValueOnce({ results: [] }) + + try { + await expect( + confluenceSelectorAttachments['confluence.spaces'].execute(spaceDetailArgs()) + ).rejects.toMatchObject({ + name: 'SelectorOptionsUnavailableError', + message: 'Options unavailable', + status: 502, + }) + } finally { + fetchProviderJson.mockRestore() + } + }) + it('preserves the first safe provider failure when both space detail requests fail', async () => { mockFetch .mockResolvedValueOnce(new Response(null, { status: 401 })) diff --git a/apps/sim/lib/selectors/server/providers/confluence.ts b/apps/sim/lib/selectors/server/providers/confluence.ts index d2ae95ada2a..dfaee7bfb51 100644 --- a/apps/sim/lib/selectors/server/providers/confluence.ts +++ b/apps/sim/lib/selectors/server/providers/confluence.ts @@ -135,7 +135,7 @@ async function executeSpaces(args: ExecuteServerSelectorArgs, identifier: 'key' if (args.request.kind === 'detail') { const requestedId = args.request.id.trim() if (!requestedId || requestedId.length > 255) throw new SelectorContextUnavailableError() - if (/^[1-9][0-9]{0,19}$/.test(requestedId)) { + if (identifier === 'id' && /^[1-9][0-9]{0,19}$/.test(requestedId)) { const space = await fetchProviderJson( `https://api.atlassian.com/ex/confluence/${auth.cloudId}/wiki/api/v2/spaces/${requestedId}`, { @@ -162,12 +162,6 @@ async function executeSpaces(args: ExecuteServerSelectorArgs, identifier: 'key' requestSpaces({ ...auth, params: paramsFor('archived'), signal: args.signal }), ]) args.signal?.throwIfAborted() - if (current.status === 'rejected' && archived.status === 'rejected') { - for (const result of [current, archived]) { - if (isPublicSelectorError(result.reason)) throw result.reason - } - throw new SelectorOptionsUnavailableError() - } const spaces = [ ...(current.status === 'fulfilled' ? (current.value.results ?? []).map((space) => ({ space, status: 'current' as const })) @@ -177,6 +171,14 @@ async function executeSpaces(args: ExecuteServerSelectorArgs, identifier: 'key' : []), ] const match = spaces.find(({ space }) => space.key === key) + if (!match && (current.status === 'rejected' || archived.status === 'rejected')) { + for (const result of [current, archived]) { + if (result.status === 'rejected' && isPublicSelectorError(result.reason)) { + throw result.reason + } + } + throw new SelectorOptionsUnavailableError() + } return detailSelectorResult( match ? { diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts index 58a875458b2..88b453da715 100644 --- a/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts @@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockResolveCredentialAccessToken = vi.hoisted(() => vi.fn()) const mockResolveOrganizationToken = vi.hoisted(() => vi.fn()) +const mockOwnAccount = vi.hoisted(() => vi.fn()) +const mockResolveManagedToken = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/knowledge/application/personal-search-account', () => ({ + authorizePersonalSearchSetupCredential: mockOwnAccount, +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ + resolveManagedOAuthToken: mockResolveManagedToken, +})) vi.mock('@/lib/credentials/application/organization-credentials', () => ({ resolveOrganizationCredentialTokenBundle: mockResolveOrganizationToken, @@ -20,6 +29,42 @@ import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/provider describe('selector credential bundles', () => { beforeEach(() => vi.clearAllMocks()) + it('resolves a personal Atlassian grant through the owned managed-account path', async () => { + mockOwnAccount.mockResolvedValue({ id: 'managed-1', providerId: 'jira' }) + mockResolveManagedToken.mockResolvedValue({ accessToken: 'own-managed-token' }) + const principal = { kind: 'session', userId: 'member-1', sessionId: 'session-1' } as const + const protectedValues = createSelectorProtectedValues() + await expect( + resolveSelectorCredentialBundle({ + credential: { + suppliedId: 'managed-1', + providerId: 'jira', + personalSearchSetup: { principal, organizationId: 'org-1', connectorType: 'jira' }, + }, + providerId: 'jira', + scopes: ['read:jira-work'], + protectedValues, + }) + ).resolves.toEqual({ accessToken: 'own-managed-token' }) + expect(mockOwnAccount).toHaveBeenCalledWith( + principal, + expect.objectContaining({ + credentialId: 'managed-1', + organizationId: 'org-1', + connectorType: 'jira', + }) + ) + expect(mockResolveManagedToken).toHaveBeenCalledWith({ + organizationId: 'org-1', + credentialId: 'managed-1', + expectedProviderId: 'jira', + requiredScopes: ['read:jira-work'], + }) + expect(mockResolveOrganizationToken).not.toHaveBeenCalled() + expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() + expect(protectedValues.contains('own-managed-token')).toBe(true) + }) + it('protects short credential-bound cloud ids as exact identifiers', async () => { mockResolveCredentialAccessToken.mockResolvedValue({ accessToken: 'server-only-token', diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.ts index 97669ff8a8f..b134cf89047 100644 --- a/apps/sim/lib/selectors/server/providers/credential-bundle.ts +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.ts @@ -3,7 +3,10 @@ import { resolveCredentialTokenBundle, type ServiceAccountTokenResult, } from '@/lib/oauth/credential-service' -import { waitForSelectorCredentialResolution } from '@/lib/selectors/server/credentials' +import { + resolveSelectorOAuthAccessToken, + waitForSelectorCredentialResolution, +} from '@/lib/selectors/server/credentials' import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' import type { AuthorizedSelectorCredential, @@ -26,6 +29,16 @@ export async function resolveSelectorCredentialBundle(input: { if (!credential) throw new SelectorConnectionUnavailableError() credential.signal?.throwIfAborted() + if (credential.personalSearchSetup) { + if (!input.providerId) throw new SelectorConnectionUnavailableError() + return { + accessToken: await resolveSelectorOAuthAccessToken({ + ...input, + credential, + serviceId: input.providerId, + }), + } + } if (credential.fixedToken) { if (input.providerId) { input.recordCredentialUse?.(credential.providerId ?? input.providerId) diff --git a/apps/sim/lib/selectors/server/providers/github.test.ts b/apps/sim/lib/selectors/server/providers/github.test.ts new file mode 100644 index 00000000000..2785a57a37f --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/github.test.ts @@ -0,0 +1,128 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + decrypt: vi.fn(), + parseBinding: vi.fn(), + list: vi.fn(), + resolve: vi.fn(), +})) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decrypt })) +vi.mock('@/lib/credentials/application/organization-credentials', () => ({ + authorizeOrganizationCredentialUse: mocks.authorize, +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error {}, + parseGitHubInstallationBinding: mocks.parseBinding, + listGitHubInstallationRepositories: mocks.list, + resolveGitHubInstallationRepository: mocks.resolve, +})) + +import { githubSelectorAttachments } from '@/lib/selectors/server/providers/github' + +const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const +const binding = { installationId: '21', accountId: '11' } +const row = { + organizationId: 'org-1', + workspaceId: null, + type: 'service_account', + providerSubjectId: '21', + providerTenantId: '11', + encryptedServiceAccountKey: 'encrypted', +} +function args(): ExecuteServerSelectorArgs { + return { + selectorKey: 'github.installationRepositories', + principal, + requesterUserId: principal.userId, + scope: { kind: 'organization', organizationId: 'org-1' }, + organizationId: 'org-1', + context: { oauthCredential: 'credential-1' }, + credential: { + suppliedId: 'credential-1', + providerId: 'github-app-installation', + organization: { principal, organizationId: 'org-1' }, + }, + request: { kind: 'list' }, + references: new Map(), + protectedValues: { add: vi.fn(), contains: () => false, containsExceptExact: () => false }, + } +} +const execute = (input: ExecuteServerSelectorArgs) => + githubSelectorAttachments['github.installationRepositories'].execute(input) + +describe('GitHub installation repository selector', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authorize.mockResolvedValue({ credential: row }) + mocks.decrypt.mockResolvedValue({ decrypted: '{}' }) + mocks.parseBinding.mockReturnValue(binding) + mocks.list.mockResolvedValue({ + repositories: [{ id: '101', fullName: 'team/repo' }], + hasMore: true, + }) + }) + it('rechecks the acting admin connection and passes only bounded repository options', async () => { + const input = args() + input.request = { kind: 'list', cursor: '2' } + input.signal = new AbortController().signal + expect(await execute(input)).toEqual({ + kind: 'list', + items: [{ id: 'team/repo', label: 'team/repo' }], + nextCursor: '3', + }) + expect(mocks.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + organizationId: 'org-1', + credentialId: 'credential-1', + expectedProviderId: 'github-app-installation', + }) + ) + expect(mocks.list).toHaveBeenCalledWith(binding, { page: 2, signal: input.signal }) + }) + it.each(['0', '101', '2.5', 'https://evil.example', '02'])( + 'refuses cursor %s before reading secrets', + async (cursor) => { + const input = args() + input.request = { kind: 'list', cursor } + await expect(execute(input)).rejects.toThrow('Context unavailable') + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() + } + ) + it('refuses workspace and mismatched organization scope', async () => { + await expect( + execute({ ...args(), scope: { kind: 'workspace', workspaceId: 'ws-1' } }) + ).rejects.toThrow('Context unavailable') + await expect(execute({ ...args(), organizationId: 'org-2' })).rejects.toThrow( + 'Context unavailable' + ) + expect(mocks.authorize).not.toHaveBeenCalled() + }) + it('never reads provider data after credential authorization fails', async () => { + mocks.authorize.mockRejectedValue(new Error('Forbidden')) + await expect(execute(args())).rejects.toThrow('Forbidden') + expect(mocks.decrypt).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() + }) + it.each([ + { ...row, organizationId: 'other' }, + { ...row, workspaceId: 'ws' }, + { ...row, providerSubjectId: '22' }, + { ...row, providerTenantId: '12' }, + ])('refuses a stale or mismatched stored binding %#', async (credential) => { + mocks.authorize.mockResolvedValue({ credential }) + await expect(execute(args())).rejects.toThrow('Connection unavailable') + expect(mocks.list).not.toHaveBeenCalled() + }) + it('bounds the last repository page explicitly', async () => { + const input = args() + input.request = { kind: 'list', cursor: '100' } + expect(await execute(input)).toMatchObject({ + diagnostics: { truncated: { reason: 'provider-cap', pages: 100 } }, + }) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/github.ts b/apps/sim/lib/selectors/server/providers/github.ts new file mode 100644 index 00000000000..f6007104014 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/github.ts @@ -0,0 +1,100 @@ +import { decryptSecret } from '@/lib/core/security/encryption' +import { authorizeOrganizationCredentialUse } from '@/lib/credentials/application/organization-credentials' +import { + GitHubInstallationError, + listGitHubInstallationRepositories, + parseGitHubInstallationBinding, + resolveGitHubInstallationRepository, +} from '@/lib/oauth/github-installation' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' +import { parseGitHubRepository } from '@/lib/oauth/github-repository' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, +} from '@/lib/selectors/server/errors' +import { + detailSelectorResult, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +export const githubSelectorAttachments = { + 'github.installationRepositories': { + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['github-repositories'] }, + destination: 'fixed', + async execute(args) { + if ( + args.scope.kind !== 'organization' || + args.workspaceId || + args.organizationId !== args.scope.organizationId || + !args.credential?.organization || + args.credential.providerId !== GITHUB_INSTALLATION_PROVIDER_ID + ) + throw new SelectorContextUnavailableError() + const page = args.request.kind === 'list' ? Number(args.request.cursor ?? '1') : 1 + if ( + !Number.isSafeInteger(page) || + page < 1 || + page > 100 || + (args.request.kind === 'list' && + args.request.cursor !== undefined && + String(page) !== args.request.cursor) + ) + throw new SelectorContextUnavailableError() + const { credential } = await authorizeOrganizationCredentialUse({ + ...args.credential.organization, + credentialId: args.credential.suppliedId, + expectedProviderId: GITHUB_INSTALLATION_PROVIDER_ID, + requestId: 'selector-execution', + purpose: 'browsing', + }) + if ( + credential.type !== 'service_account' || + credential.organizationId !== args.scope.organizationId || + credential.workspaceId !== null || + !credential.encryptedServiceAccountKey || + credential.encryptedServiceAccountKey.length > 16_384 + ) + throw new SelectorConnectionUnavailableError() + const { decrypted } = await decryptSecret(credential.encryptedServiceAccountKey) + args.protectedValues.add(decrypted) + const binding = parseGitHubInstallationBinding(JSON.parse(decrypted)) + if ( + binding.installationId !== credential.providerSubjectId || + binding.accountId !== credential.providerTenantId + ) + throw new SelectorConnectionUnavailableError() + try { + if (args.request.kind === 'detail') { + try { + parseGitHubRepository(args.request.id) + } catch { + return detailSelectorResult(null) + } + const repository = await resolveGitHubInstallationRepository(binding, args.request.id, { + signal: args.signal, + }) + return detailSelectorResult({ id: repository.fullName, label: repository.fullName }) + } + const result = await listGitHubInstallationRepositories(binding, { + page, + signal: args.signal, + }) + return listSelectorResult( + result.repositories.map((repository) => ({ + id: repository.fullName, + label: repository.fullName, + })), + result.hasMore && page < 100 ? String(page + 1) : undefined, + result.hasMore && page === 100 + ? { truncated: { reason: 'provider-cap', pages: 100 } } + : undefined + ) + } catch (error) { + if (args.signal?.aborted) throw error + if (error instanceof GitHubInstallationError) throw new SelectorConnectionUnavailableError() + throw error + } + }, + }, +} satisfies ServerSelectorAttachmentMap<'github.installationRepositories'> diff --git a/apps/sim/lib/selectors/server/providers/jira.test.ts b/apps/sim/lib/selectors/server/providers/jira.test.ts index d4ea2fcf665..a3807abda7a 100644 --- a/apps/sim/lib/selectors/server/providers/jira.test.ts +++ b/apps/sim/lib/selectors/server/providers/jira.test.ts @@ -87,12 +87,14 @@ describe('Jira server selector adapter', () => { ) it.each([ - ['jira.projects', 'ENG'], - ['jira.projectKeys', 'ENG'], - ['jira.projectKeys', '10001'], + ['jira.projects', 'ENG', 'ENG'], + ['jira.projects', '10001', '10001'], + ['jira.projectKeys', 'ENG', 'ENG'], + ['jira.projectKeys', '10001', 'ENG'], + ['jira.projectKeys', 'LEGACY', 'ENG'], ] as const)( - 'hydrates %s selection %s without replacing its saved value', - async (selectorKey, id) => { + 'hydrates %s selection %s as %s, preserving ID-selector aliases and returning canonical keys', + async (selectorKey, id, expectedId) => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ id: '10001', key: 'ENG', name: 'Engineering' }), { status: 200, @@ -107,7 +109,7 @@ describe('Jira server selector adapter', () => { }) ).resolves.toEqual({ kind: 'detail', - item: { id, label: 'Engineering' }, + item: { id: expectedId, label: 'Engineering' }, }) expect(String(mockFetch.mock.calls[0]?.[0])).toBe( `https://api.atlassian.com/ex/jira/cloud-1/rest/api/3/project/${id}` diff --git a/apps/sim/lib/selectors/server/providers/jira.ts b/apps/sim/lib/selectors/server/providers/jira.ts index bf5e028837a..e27f4017516 100644 --- a/apps/sim/lib/selectors/server/providers/jira.ts +++ b/apps/sim/lib/selectors/server/providers/jira.ts @@ -149,7 +149,10 @@ async function getProject(args: ExecuteServerSelectorArgs, projectId: string) { ) const parsed = jiraProjectSchema.safeParse(body) if (!parsed.success) throw new SelectorOptionsUnavailableError() - return { id: projectId, label: parsed.data.name } + return { + id: args.selectorKey === 'jira.projectKeys' ? parsed.data.key : projectId, + label: parsed.data.name, + } } async function fetchIssues( diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts index 4e067cc778e..09441960e09 100644 --- a/apps/sim/lib/selectors/server/registry.ts +++ b/apps/sim/lib/selectors/server/registry.ts @@ -9,6 +9,7 @@ import { calcomSelectorAttachments } from '@/lib/selectors/server/providers/calc import { clickupSelectorAttachments } from '@/lib/selectors/server/providers/clickup' import { cloudWatchSelectorAttachments } from '@/lib/selectors/server/providers/cloudwatch' import { confluenceSelectorAttachments } from '@/lib/selectors/server/providers/confluence' +import { githubSelectorAttachments } from '@/lib/selectors/server/providers/github' import { googleSelectorAttachments } from '@/lib/selectors/server/providers/google' import { harmonicSelectorAttachments } from '@/lib/selectors/server/providers/harmonic' import { hubspotSelectorAttachments } from '@/lib/selectors/server/providers/hubspot' @@ -46,6 +47,7 @@ export const serverSelectorRegistry = { ...cloudWatchSelectorAttachments, ...confluenceSelectorAttachments, ...googleSelectorAttachments, + ...githubSelectorAttachments, ...harmonicSelectorAttachments, ...hubspotSelectorAttachments, ...imapSelectorAttachments, diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 05546532f2d..a8d12395bb6 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -51,6 +51,11 @@ export type SelectorCredentialPolicy = export interface AuthorizedSelectorCredential { suppliedId: string organization?: { principal: SessionPrincipal; organizationId: string } + personalSearchSetup?: { + principal: SessionPrincipal + organizationId: string + connectorType: 'jira' | 'confluence' + } access?: CredentialAccessResult fixedToken?: string /** Trusted provider id loaded during server-side credential binding. */ diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts index c97382e32f0..2a20eac3cde 100644 --- a/apps/sim/lib/selectors/types.ts +++ b/apps/sim/lib/selectors/types.ts @@ -104,6 +104,13 @@ export type SelectorScope = workspaceId: string } +/** Chooses a dedicated client transport without granting access through the generic selector API. */ +export interface SelectorSurface { + kind: 'personal-search-setup' + organizationId: string + connectorType: 'jira' | 'confluence' +} + export type SelectorRequest = | { kind: 'list' diff --git a/apps/sim/lib/sim-search/connection-labels.ts b/apps/sim/lib/sim-search/connection-labels.ts new file mode 100644 index 00000000000..da7ee159a52 --- /dev/null +++ b/apps/sim/lib/sim-search/connection-labels.ts @@ -0,0 +1,71 @@ +interface SearchConnectionLabels { + add: string + title: string + empty: string + searchPlaceholder: string +} + +const CONNECTION_LABELS: Record = { + confluence: { + add: 'Add Confluence site', + title: 'Connect Confluence site', + empty: 'No Confluence sites connected.', + searchPlaceholder: 'Search Confluence sites...', + }, + jira: { + add: 'Add projects', + title: 'Add Jira projects', + empty: 'No Jira projects added.', + searchPlaceholder: 'Search Jira projects...', + }, + github: { + add: 'Add repository', + title: 'Add GitHub repository', + empty: 'No GitHub repositories added.', + searchPlaceholder: 'Search GitHub repositories...', + }, + gitlab: { + add: 'Add project', + title: 'Add GitLab project', + empty: 'No GitLab projects added.', + searchPlaceholder: 'Search GitLab projects...', + }, + slack: { + add: 'Add channels or DMs', + title: 'Choose Slack channels and DMs', + empty: 'No Slack connections yet.', + searchPlaceholder: 'Search Slack connections...', + }, +} +const GOOGLE_NAMES: Record = { + gmail: 'Gmail', + google_calendar: 'Google Calendar', + google_drive: 'Google Drive', +} + +/** Labels describe the connection being configured, not the internal connector record. */ +export function getSearchConnectionLabels( + connectorType: string, + accessMode: 'admin' | 'members' | 'workspace' = 'admin' +): SearchConnectionLabels { + const googleName = GOOGLE_NAMES[connectorType] + if (googleName) { + return { + add: accessMode === 'members' ? 'Set up member accounts' : 'Connect service account', + title: + accessMode === 'members' + ? `Set up ${googleName} member accounts` + : `Connect ${googleName} service account`, + empty: `No ${googleName} connections yet.`, + searchPlaceholder: `Search ${googleName} connections...`, + } + } + return ( + CONNECTION_LABELS[connectorType] ?? { + add: 'Add connection', + title: 'Add connection', + empty: 'No connections yet.', + searchPlaceholder: 'Search connections...', + } + ) +} diff --git a/apps/sim/lib/sim-search/personal-source-setup.ts b/apps/sim/lib/sim-search/personal-source-setup.ts new file mode 100644 index 00000000000..8afb1049c9f --- /dev/null +++ b/apps/sim/lib/sim-search/personal-source-setup.ts @@ -0,0 +1,2 @@ +/** Personal Atlassian setup stores an explicit, bounded snapshot of selected project or space keys. */ +export const MAX_PERSONAL_SOURCE_SETUP_KEYS = 1000 diff --git a/apps/sim/lib/slack-search/assistant-stream.test.ts b/apps/sim/lib/slack-search/assistant-stream.test.ts index f2cdfef06e3..5a3b53ce65d 100644 --- a/apps/sim/lib/slack-search/assistant-stream.test.ts +++ b/apps/sim/lib/slack-search/assistant-stream.test.ts @@ -86,7 +86,7 @@ function setup(deliverConnections = vi.fn().mockResolvedValue(undefined)) { } } -function toolCall(toolName = 'list_integrations', toolCallId = 'tool-1'): ToolCallStreamEvent { +function toolCall(toolName = 'search_workspace', toolCallId = 'tool-1'): ToolCallStreamEvent { return { type: 'tool', payload: { @@ -101,7 +101,7 @@ function toolCall(toolName = 'list_integrations', toolCallId = 'tool-1'): ToolCa } function toolResult( - toolName = 'list_integrations', + toolName = 'search_workspace', toolCallId = 'tool-1', success = true ): ToolResultStreamEvent { @@ -386,7 +386,6 @@ describe('Slack tool progress', () => { }) it.each([ - ['list_integrations', 'Listing connected integrations…'], ['search_workspace', 'Searching documents…'], ['read_document', 'Reading documents…'], ])('shows %s as a task and completes that same task once', async (name, title) => { @@ -446,7 +445,7 @@ describe('Slack tool progress', () => { ...call, payload: { ...call.payload, arguments: { query: 'private argument' } }, }) - const failed = toolResult('list_integrations', 'tool-1', false) + const failed = toolResult('search_workspace', 'tool-1', false) await stream.onEvent({ ...failed, payload: { diff --git a/apps/sim/lib/slack-search/assistant-stream.ts b/apps/sim/lib/slack-search/assistant-stream.ts index 07767a48d2d..c6d85a9d95d 100644 --- a/apps/sim/lib/slack-search/assistant-stream.ts +++ b/apps/sim/lib/slack-search/assistant-stream.ts @@ -107,7 +107,6 @@ const FAILURE_BLOCKS: Record[] = [ ] const TOOL_PROGRESS_TITLES = new Map([ - ['list_integrations', 'Listing connected integrations…'], ['search_workspace', 'Searching documents…'], ['read_document', 'Reading documents…'], ]) diff --git a/apps/sim/lib/slack-search/commands.ts b/apps/sim/lib/slack-search/commands.ts new file mode 100644 index 00000000000..e76c48249a8 --- /dev/null +++ b/apps/sim/lib/slack-search/commands.ts @@ -0,0 +1,19 @@ +import { sha256Hex } from '@sim/security/hash' +import { z } from 'zod' + +const id = z.string().min(1).max(200) +export const slackSearchCommandSchema = z.object({ + api_app_id: id, + team_id: id, + user_id: id, + channel_id: z.string().regex(/^[CGD][A-Z0-9]+$/), + command: z.enum(['/query', '/connect']), + text: z.string().max(40_000), + trigger_id: z.string().min(1).max(200), +}) +export type SlackSearchCommand = z.infer + +/** A verified invocation ID, independent of delivery retries and unrelated to message timestamps. */ +export function slackSearchCommandEventId(command: SlackSearchCommand) { + return `command:${sha256Hex(JSON.stringify([command.api_app_id, command.team_id, command.user_id, command.trigger_id]))}` +} diff --git a/apps/sim/lib/slack-search/constants.ts b/apps/sim/lib/slack-search/constants.ts index 8aeeb839f5f..def905139d5 100644 --- a/apps/sim/lib/slack-search/constants.ts +++ b/apps/sim/lib/slack-search/constants.ts @@ -7,6 +7,7 @@ export const SLACK_SEARCH_SCOPES = [ 'users:read', 'users:read.email', ] as const +export const SLACK_SHARED_SEARCH_BOT_SCOPES = [...SLACK_SEARCH_SCOPES, 'commands'] as const export const SLACK_SEARCH_MAX_DURATION_SECONDS = 180 export const SLACK_SEARCH_CONCURRENCY = 2 export const SLACK_SEARCH_MAX_PENDING_TURNS = 20 diff --git a/apps/sim/lib/slack-search/conversation.ts b/apps/sim/lib/slack-search/conversation.ts index 54b717effe4..26f6e975020 100644 --- a/apps/sim/lib/slack-search/conversation.ts +++ b/apps/sim/lib/slack-search/conversation.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import type { SlackSearchJob } from '@/lib/slack-search/types' +import { slackSearchThreadTimestamp } from '@/lib/slack-search/types' const slackTimestamp = z.string().regex(/^\d{1,12}\.\d{1,9}$/) export const slackSearchConversationSchema = z @@ -41,7 +42,7 @@ export function slackSearchConversation(job: SlackSearchJob): SlackSearchConvers type: 'slack', installationId: job.installationId, channelId: job.message.channelId, - threadTs: job.message.threadTs ?? job.message.messageTs, + threadTs: slackSearchThreadTimestamp(job.message), slackUserId: job.message.userId, lastStopTs: null, ...(job.message.origin ? { origin: job.message.origin } : {}), diff --git a/apps/sim/lib/slack-search/manifest.test.ts b/apps/sim/lib/slack-search/manifest.test.ts index d8b0b6f46ec..12c578edd47 100644 --- a/apps/sim/lib/slack-search/manifest.test.ts +++ b/apps/sim/lib/slack-search/manifest.test.ts @@ -1,7 +1,10 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { createSlackSearchManifest } from '@/lib/slack-search/manifest' +import { + createSharedSlackSearchManifest, + createSlackSearchManifest, +} from '@/lib/slack-search/manifest' describe('Search app manifest', () => { it('combines bot conversations and member indexing in one app with separate grants', () => { @@ -88,3 +91,33 @@ describe('Search app manifest', () => { ).toThrow(OrchestrationError) }) }) + +it('official app uses the existing personal indexing grants with bot commands', () => { + const manifest = createSharedSlackSearchManifest('https://www.sim.ai') + expect(manifest.oauth_config.scopes.user).toEqual([ + 'channels:history', + 'channels:read', + 'groups:history', + 'groups:read', + 'im:history', + 'im:read', + 'mpim:history', + 'mpim:read', + 'users:read', + 'users:read.email', + ]) + expect(manifest.oauth_config.scopes.bot).toContain('commands') + expect(manifest.oauth_config.scopes.bot).not.toContain('groups:history') + expect(manifest.features.slash_commands.map((command) => command.command)).toEqual([ + '/query', + '/connect', + ]) + expect( + manifest.features.slash_commands.every( + (command) => command.url === 'https://www.sim.ai/api/webhooks/slack' + ) + ).toBe(true) + expect(manifest.settings.event_subscriptions.bot_events).toContain('tokens_revoked') + expect(manifest.settings.event_subscriptions.bot_events).not.toContain('message.channels') + expect(manifest.settings.event_subscriptions).not.toHaveProperty('user_events') +}) diff --git a/apps/sim/lib/slack-search/manifest.ts b/apps/sim/lib/slack-search/manifest.ts index a1b73ca8a55..cadeba498c0 100644 --- a/apps/sim/lib/slack-search/manifest.ts +++ b/apps/sim/lib/slack-search/manifest.ts @@ -4,7 +4,7 @@ import { SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, SLACK_SEARCH_USER_SCOPES, } from '@/lib/credential-groups/slack-managed-user-scopes' -import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' +import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' export const SLACK_SEARCH_CALLBACK_PATH = '/api/knowledge/slack/oauth/callback' export const SLACK_SEARCH_WEBHOOK_PATH = '/api/webhooks/slack' @@ -61,3 +61,53 @@ export function createSlackSearchManifest( }, } } + +/** The official app combines personal source indexing with bot conversations and commands. */ +export function createSharedSlackSearchManifest(origin: string) { + const manifest = createSlackSearchManifest( + SLACK_SEARCH_DEFAULT_NAME, + SLACK_SEARCH_DEFAULT_DESCRIPTION, + origin + ) + const webhook = new URL(SLACK_SEARCH_WEBHOOK_PATH, origin).href + return { + ...manifest, + features: { + ...manifest.features, + slash_commands: [ + { + command: '/query', + description: 'Ask Sim Search a question privately', + usage_hint: '[question]', + url: webhook, + should_escape: false, + }, + { + command: '/connect', + description: 'Connect your personal sources in Sim', + usage_hint: '[provider]', + url: webhook, + should_escape: false, + }, + ], + }, + oauth_config: { + ...manifest.oauth_config, + scopes: { + bot: [...SLACK_SHARED_SEARCH_BOT_SCOPES], + user: [...SLACK_SEARCH_USER_SCOPES], + }, + }, + settings: { + ...manifest.settings, + event_subscriptions: { + ...manifest.settings.event_subscriptions, + bot_events: [ + ...manifest.settings.event_subscriptions.bot_events, + 'app_uninstalled', + 'tokens_revoked', + ], + }, + }, + } +} diff --git a/apps/sim/lib/slack-search/oauth-state.ts b/apps/sim/lib/slack-search/oauth-state.ts index b570a416030..a1257f0582b 100644 --- a/apps/sim/lib/slack-search/oauth-state.ts +++ b/apps/sim/lib/slack-search/oauth-state.ts @@ -12,6 +12,7 @@ const attemptSchema = z.object({ organizationId: z.string().min(1), name: z.string().min(1), description: z.string().min(1), + sharedApp: z.object({ id: z.string().min(1), revision: z.string().min(1) }).optional(), memberApp: z.object({ appId: z.string().min(1), teamId: z.string().min(1) }).optional(), clientId: z.string().min(1), encryptedClientSecret: z.string().min(1), diff --git a/apps/sim/lib/slack-search/shared-app.test.ts b/apps/sim/lib/slack-search/shared-app.test.ts new file mode 100644 index 00000000000..ce541a84e61 --- /dev/null +++ b/apps/sim/lib/slack-search/shared-app.test.ts @@ -0,0 +1,54 @@ +/** @vitest-environment node */ +import { slackApp, slackSearchInstallation } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ flag: vi.fn(), env: { SLACK_SEARCH_APP_ID: 'A1' } })) +vi.mock('@/lib/core/config/env', () => ({ env: m.env })) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: m.flag })) + +import { + findSharedSlackSearchInstallation, + readSharedSlackSearchApp, + requireSlackSearchAppAvailable, +} from '@/lib/slack-search/shared-app' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + m.env.SLACK_SEARCH_APP_ID = 'A1' + m.flag.mockResolvedValue(true) +}) +describe('shared Slack rollout', () => { + it.each([false, true])('requires both flag and configured app (flag=%s)', async (flag) => { + m.flag.mockResolvedValue(flag) + if (flag) m.env.SLACK_SEARCH_APP_ID = '' + await expect(readSharedSlackSearchApp()).resolves.toBeNull() + }) + it.each( + [ + [], + [{ id: 'A1', kind: 'custom', organizationId: 'org' }], + [{ id: 'A1', kind: 'shared', organizationId: 'org' }], + ].map((rows) => ({ rows })) + )('fails closed for invalid registration %#', async ({ rows }) => { + queueTableRows(slackApp, rows) + await expect(readSharedSlackSearchApp()).rejects.toThrow('not registered') + }) + it('preserves custom bot handling while the shared flag is off', async () => { + m.flag.mockResolvedValue(false) + queueTableRows(slackApp, [{ kind: 'custom' }]) + await expect(requireSlackSearchAppAvailable('CUSTOM')).resolves.toBeUndefined() + expect(m.flag).not.toHaveBeenCalled() + }) + it('refuses a shared bot while the shared flag is off', async () => { + m.flag.mockResolvedValue(false) + queueTableRows(slackApp, [{ kind: 'shared' }]) + await expect(requireSlackSearchAppAvailable('A1')).rejects.toThrow('unavailable') + }) + it('rejects ambiguous organization installations', async () => { + queueTableRows(slackApp, [{ id: 'A1', kind: 'shared', organizationId: null }]) + queueTableRows(slackSearchInstallation, [{ id: 'one' }, { id: 'two' }]) + await expect(findSharedSlackSearchInstallation('org')).rejects.toThrow('single Slack workspace') + }) +}) diff --git a/apps/sim/lib/slack-search/shared-app.ts b/apps/sim/lib/slack-search/shared-app.ts new file mode 100644 index 00000000000..8b87c0e2c22 --- /dev/null +++ b/apps/sim/lib/slack-search/shared-app.ts @@ -0,0 +1,52 @@ +import { db } from '@sim/db' +import { slackApp, slackSearchInstallation } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { env } from '@/lib/core/config/env' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Called only inside authorized installation/member operations; never returns secrets to a surface. */ +export async function readSharedSlackSearchApp() { + if (!(await isFeatureEnabled('slack-search-shared-app')) || !env.SLACK_SEARCH_APP_ID) return null + const [app] = await db + .select() + .from(slackApp) + .where(eq(slackApp.id, env.SLACK_SEARCH_APP_ID)) + .limit(1) + if (!app || app.kind !== 'shared' || app.organizationId !== null) + throw new Error('The configured shared Slack Search app is not registered') + return app +} + +/** Existing custom bots remain independent of the shared-app rollout. */ +export async function requireSlackSearchAppAvailable(appId: string) { + const [app] = await db + .select({ kind: slackApp.kind }) + .from(slackApp) + .where(eq(slackApp.id, appId)) + .limit(1) + if (app?.kind !== 'shared') return + const configured = await readSharedSlackSearchApp() + if (configured?.id !== appId) + throw new OrchestrationError('forbidden', 'The shared Slack Search app is unavailable') +} + +/** Canonical lookup inside an authorized organization operation. */ +export async function findSharedSlackSearchInstallation(organizationId: string) { + const app = await readSharedSlackSearchApp() + if (!app) return null + const installations = await db + .select() + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.organizationId, organizationId), + eq(slackSearchInstallation.slackAppId, app.id), + eq(slackSearchInstallation.enabled, true) + ) + ) + .limit(2) + if (installations.length > 1) + throw new OrchestrationError('conflict', 'Select a single Slack workspace for Search') + return installations[0] ? { ...installations[0], appRevision: app.revision } : null +} diff --git a/apps/sim/lib/slack-search/types.ts b/apps/sim/lib/slack-search/types.ts index 63458f543b0..e81c2341038 100644 --- a/apps/sim/lib/slack-search/types.ts +++ b/apps/sim/lib/slack-search/types.ts @@ -1,27 +1,36 @@ import { z } from 'zod' const id = z.string().min(1).max(200) -export const slackSearchMessageSchema = z.object({ - appId: id, - teamId: id, - eventId: id, - userId: id, - channelId: z.string().regex(/^[CGD][A-Z0-9]+$/), - origin: z - .object({ - channelId: z.string().regex(/^[CG][A-Z0-9]+$/), - threadTs: z.string().regex(/^\d+\.\d+$/), - messageTs: z.string().regex(/^\d+\.\d+$/), - }) - .optional(), - messageTs: z.string().regex(/^\d+\.\d+$/), - threadTs: z - .string() - .regex(/^\d+\.\d+$/) - .optional(), - query: z.string().trim().max(2000), - queryTooLong: z.boolean(), -}) +export const slackSearchMessageSchema = z + .object({ + appId: id, + teamId: id, + eventId: id, + userId: id, + channelId: z.string().regex(/^[CGD][A-Z0-9]+$/), + origin: z + .object({ + channelId: z.string().regex(/^[CG][A-Z0-9]+$/), + threadTs: z.string().regex(/^\d+\.\d+$/), + messageTs: z.string().regex(/^\d+\.\d+$/), + }) + .optional(), + messageTs: z + .string() + .regex(/^\d+\.\d+$/) + .nullable(), + command: z.literal('/query').optional(), + threadTs: z + .string() + .regex(/^\d+\.\d+$/) + .optional(), + query: z.string().trim().max(2000), + queryTooLong: z.boolean(), + }) + .refine( + (message) => message.messageTs !== null || (message.command === '/query' && !message.threadTs), + 'Only a queued slash command may lack a Slack message timestamp' + ) export type SlackSearchMessage = z.infer /** Queue-only contract; no secrets, results, or caller-supplied Sim user identity. */ @@ -102,3 +111,10 @@ export function parseSlackSearchMessage( queryTooLong: query.length > 2000, }) } + +/** A command obtains this identity only after Slack acknowledges its private root message. */ +export function slackSearchThreadTimestamp(message: SlackSearchMessage): string { + const timestamp = message.threadTs ?? message.messageTs + if (!timestamp) throw new Error('Slack command has not created its private thread yet') + return timestamp +} diff --git a/apps/sim/lib/speech/application/create-token.ts b/apps/sim/lib/speech/application/create-token.ts new file mode 100644 index 00000000000..a9f09027186 --- /dev/null +++ b/apps/sim/lib/speech/application/create-token.ts @@ -0,0 +1,155 @@ +import { createHash } from 'node:crypto' +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { + type BillingAttributionSnapshot, + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveOrganizationBillingAttribution, + toBillingContext, +} from '@/lib/billing/core/billing-attribution' +import { recordUsage } from '@/lib/billing/core/usage-log' +import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { env } from '@/lib/core/config/env' +import { getCostMultiplier } from '@/lib/core/config/env-flags' +import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('SpeechToken') +const ELEVENLABS_TOKEN_URL = 'https://api.elevenlabs.io/v1/single-use-token/realtime_scribe' +const VOICE_SESSION_COST_PER_MIN = 0.008 +const VOICE_SESSION_MAX_MINUTES = 3 +const providerTokenSchema = z.object({ token: z.string().min(1) }) +const providerErrorSchema = z.object({ + detail: z.string().optional(), + message: z.string().optional(), +}) + +/** + * permission-group-exempt: Voice dictation is an input aid shared across product surfaces. + */ +export const speechTokenOperation = defineWorkspaceOperation({ + id: 'speech.token.create', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'none', +}) + +/** + * permission-group-exempt: Voice dictation is an input aid shared across product surfaces. + */ +const organizationSpeechTokenOperation = defineOrganizationOperation({ + id: 'speech.token.create', + minimumRole: 'member', + principalKinds: ['session'], + capability: 'none', +}) + +export class SpeechTokenError extends Error { + constructor( + readonly reason: 'usage_limit' | 'unconfigured' | 'provider_failed', + message: string, + readonly scope?: 'actor' | 'payer' | 'member' + ) { + super(message) + this.name = 'SpeechTokenError' + } +} + +async function issueSpeechToken( + actorUserId: string, + billingAttribution: BillingAttributionSnapshot +) { + /** Admission remains bounded by the per-user token bucket; billing has no reservation primitive. */ + const usageCheck = await checkAttributedUsageLimits(billingAttribution) + if (usageCheck.isExceeded) { + throw new SpeechTokenError( + 'usage_limit', + usageCheck.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + usageCheck.scope + ) + } + + const apiKey = env.ELEVENLABS_API_KEY + if (!apiKey?.trim()) { + throw new SpeechTokenError('unconfigured', 'Speech-to-text service is not configured') + } + + const response = await fetch(ELEVENLABS_TOKEN_URL, { + method: 'POST', + headers: { 'xi-api-key': apiKey }, + }) + if (!response.ok) { + const error = providerErrorSchema.safeParse(await response.json().catch(() => ({}))) + const message = + (error.success && (error.data.detail || error.data.message)) || + `Token request failed (${response.status})` + logger.error('ElevenLabs token request failed', { status: response.status, message }) + throw new SpeechTokenError('provider_failed', message) + } + + const { token } = providerTokenSchema.parse(await response.json()) + try { + await recordUsage({ + userId: actorUserId, + ...(billingAttribution.workspaceId ? { workspaceId: billingAttribution.workspaceId } : {}), + ...toBillingContext(billingAttribution), + entries: [ + { + category: 'fixed', + source: 'voice-input', + description: `Voice input session (${VOICE_SESSION_MAX_MINUTES} min)`, + cost: VOICE_SESSION_COST_PER_MIN * VOICE_SESSION_MAX_MINUTES * getCostMultiplier(), + sourceReference: `voice-input:${createHash('sha256').update(token).digest('hex')}`, + }, + ], + }) + await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity) + } catch (error) { + logger.warn('Failed to record voice input usage, continuing:', error) + } + return { token } +} + +const createWorkspaceSpeechToken = defineAuthorizedWorkspaceUseCase({ + operation: speechTokenOperation, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: {}, + async execute({ principal, context }) { + const attribution = await resolveBillingAttribution({ + actorUserId: principal.userId, + workspaceId: context.workspaceId, + }) + return issueSpeechToken(principal.userId, attribution) + }, +}) + +/** Issues a metered token under the caller's current workspace or organization membership. */ +export const createSpeechToken = { + operation: speechTokenOperation, + async execute({ principal, input }: { principal: Principal; input: ResourceOwner }) { + requireAllowedWorkspacePrincipal(principal, speechTokenOperation) + const scope = resourceScopeFromOwner(input) + if (scope.kind === 'workspace') { + return createWorkspaceSpeechToken.execute({ principal, input: scope }) + } + const context = await authorizeOrganizationOperation( + principal, + organizationSpeechTokenOperation, + scope + ) + const attribution = await resolveOrganizationBillingAttribution({ + actorUserId: context.userId, + organizationId: context.organizationId, + }) + return issueSpeechToken(context.userId, attribution) + }, +} diff --git a/apps/sim/lib/terminal/focus.ts b/apps/sim/lib/terminal/focus.ts new file mode 100644 index 00000000000..91036bbc64d --- /dev/null +++ b/apps/sim/lib/terminal/focus.ts @@ -0,0 +1,30 @@ +/** + * "Put the keyboard in this terminal" — sent by the resource strip when the + * user picks a terminal tab with the pointer or opens a new shell. The strip + * cannot reach the panel's xterm instances, so the request travels as a + * window CustomEvent and the terminal panel subscribes via + * {@link onTerminalFocusRequest}. Keyboard navigation along the strip does + * not send one, so arrow keys keep working there. + */ +const TERMINAL_FOCUS_EVENT = 'sim:focus-terminal' + +interface TerminalFocusDetail { + terminalId: string +} + +/** Asks the terminal panel to focus one shell once it is on screen. */ +export function requestTerminalFocus(terminalId: string): void { + window.dispatchEvent( + new CustomEvent(TERMINAL_FOCUS_EVENT, { detail: { terminalId } }) + ) +} + +/** Subscribes the terminal panel to focus requests; returns an unsubscribe. */ +export function onTerminalFocusRequest(callback: (terminalId: string) => void): () => void { + const listener = (event: Event) => { + const terminalId = (event as CustomEvent).detail?.terminalId + if (typeof terminalId === 'string' && terminalId) callback(terminalId) + } + window.addEventListener(TERMINAL_FOCUS_EVENT, listener) + return () => window.removeEventListener(TERMINAL_FOCUS_EVENT, listener) +} diff --git a/apps/sim/lib/terminal/resource-id.test.ts b/apps/sim/lib/terminal/resource-id.test.ts new file mode 100644 index 00000000000..8aea74bce7d --- /dev/null +++ b/apps/sim/lib/terminal/resource-id.test.ts @@ -0,0 +1,12 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { terminalIdFromResourceId, terminalResourceId } from '@/lib/terminal/resource-id' + +describe('terminal resource ids', () => { + it('keeps a shell apart from a browser page with the same native id', () => { + expect(terminalResourceId('1')).not.toBe('1') + expect(terminalIdFromResourceId(terminalResourceId('1'))).toBe('1') + }) +}) diff --git a/apps/sim/lib/terminal/resource-id.ts b/apps/sim/lib/terminal/resource-id.ts new file mode 100644 index 00000000000..76b86d27e2f --- /dev/null +++ b/apps/sim/lib/terminal/resource-id.ts @@ -0,0 +1,17 @@ +const TERMINAL_RESOURCE_PREFIX = 'terminal:' + +/** + * The resource id for a live shell. Native terminal ids and browser tab ids + * are both small per-chat counters, and the strip resolves resources by id + * alone, so a shell's resource carries a namespace the page's does not. + */ +export function terminalResourceId(terminalId: string): string { + return `${TERMINAL_RESOURCE_PREFIX}${terminalId}` +} + +/** The native terminal id behind a terminal resource. */ +export function terminalIdFromResourceId(resourceId: string): string { + return resourceId.startsWith(TERMINAL_RESOURCE_PREFIX) + ? resourceId.slice(TERMINAL_RESOURCE_PREFIX.length) + : resourceId +} diff --git a/apps/sim/lib/terminal/tab-label.test.ts b/apps/sim/lib/terminal/tab-label.test.ts new file mode 100644 index 00000000000..6c03dcadafb --- /dev/null +++ b/apps/sim/lib/terminal/tab-label.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import type { TerminalTabState } from '@sim/terminal-protocol' +import { describe, expect, it } from 'vitest' +import { terminalTabTitle, terminalTooltip } from '@/lib/terminal/tab-label' + +const idleTab: TerminalTabState = { + terminalId: 'terminal-1', + title: 'sim', + cwd: '/Users/ada/sim', + running: null, + interactive: false, + active: true, +} + +describe('terminalTooltip', () => { + it('summarizes a long compound heredoc command by its foreground program', () => { + const running = `mkdir -p ~/.bot/bin && cat > ~/.bot/bin/cli-mock <<'END' +#!/usr/bin/env node +const carts = new Map() +process.stdout.write(JSON.stringify([...carts])) +END +chmod +x ~/.bot/bin/cli-mock && echo '--- smoke test ---' && ~/.bot/bin/cli-mock submit mock_123` + const tooltip = terminalTooltip({ ...idleTab, title: 'mkdir', cwd: '/Users/ada', running }) + + expect(tooltip).toBe('/Users/ada — cli-mock') + expect(tooltip).not.toContain('const carts') + }) + + it('preserves the working-directory tooltip for idle terminals', () => { + expect(terminalTooltip(idleTab)).toBe('/Users/ada/sim') + expect(terminalTooltip({ ...idleTab, cwd: null })).toBe('Terminal') + }) +}) + +describe('terminalTabTitle', () => { + it('names an idle shell after its directory', () => { + expect(terminalTabTitle(idleTab, new Set())).toBe('sim') + }) + + it('names a shell after a command only once it has settled', () => { + const building = { ...idleTab, running: 'bun run build' } + expect(terminalTabTitle(building, new Set())).toBe('sim') + expect(terminalTabTitle(building, new Set(['terminal-1']))).toBe('bun run build') + }) + + it('names a full-screen program immediately', () => { + expect(terminalTabTitle({ ...idleTab, running: 'vim', interactive: true }, new Set())).toBe( + 'vim' + ) + }) +}) diff --git a/apps/sim/lib/terminal/tab-label.ts b/apps/sim/lib/terminal/tab-label.ts new file mode 100644 index 00000000000..08f783888f7 --- /dev/null +++ b/apps/sim/lib/terminal/tab-label.ts @@ -0,0 +1,23 @@ +import { describeRunningCommand, type TerminalTabState } from '@sim/terminal-protocol' + +/** Full working directory, plus a concise name for whatever the shell is running. */ +export function terminalTooltip(tab: TerminalTabState): string { + const where = tab.cwd ?? 'Terminal' + return tab.running ? `${where} — ${describeRunningCommand(tab.running)}` : where +} + +/** + * Whether a tab should be named after what it is running rather than where it + * is. A full-screen program is named the moment it appears: the delay exists + * to stop `ls` flickering the label, and an editor or coding agent is not a + * transient command — it holds the terminal until it is quit, so there is + * nothing to wait out. + */ +export function namesItsCommand(tab: TerminalTabState, settled: ReadonlySet): boolean { + return Boolean(tab.running) && (tab.interactive || settled.has(tab.terminalId)) +} + +/** The strip label for a terminal: its settled foreground program, else its cwd basename. */ +export function terminalTabTitle(tab: TerminalTabState, settled: ReadonlySet): string { + return namesItsCommand(tab, settled) ? (tab.running ?? tab.title) : tab.title +} diff --git a/apps/sim/lib/terminal/transport.test.ts b/apps/sim/lib/terminal/transport.test.ts index 637f8b95c5a..d2a8ac788dd 100644 --- a/apps/sim/lib/terminal/transport.test.ts +++ b/apps/sim/lib/terminal/transport.test.ts @@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { activateScope, + activateStoreScope, applyCommandEvent, clearScrollback, discardScope, @@ -11,15 +12,19 @@ const { markScopeSuspended, migrateStoreScope, nativeMigrateScope, + nativeOpenTerminal, nativeReorderTerminal, + nativeStart, onCommand, onData, onDefaultZoomChanged, onShortcutCommand, onTabs, onScopeSuspended, + restoreScope, setTabs, nativeSuspendScope, + nativeSwitchTerminal, write, } = vi.hoisted(() => ({ activateScope: vi.fn(async (scopeId: string) => ({ @@ -27,6 +32,7 @@ const { tabs: [], activeTerminalId: null, })), + activateStoreScope: vi.fn(), applyCommandEvent: vi.fn(), clearScrollback: vi.fn(async () => true), discardScope: vi.fn(), @@ -39,45 +45,58 @@ const { markScopeSuspended: vi.fn(), migrateStoreScope: vi.fn(), nativeMigrateScope: vi.fn(), + nativeOpenTerminal: vi.fn(async (_cwd: string | undefined, scopeId: string) => ({ + scopeId, + tabs: [], + activeTerminalId: null, + })), nativeReorderTerminal: vi.fn(), + nativeStart: vi.fn(), onCommand: vi.fn(), onData: vi.fn(() => vi.fn()), onDefaultZoomChanged: vi.fn(() => vi.fn()), onShortcutCommand: vi.fn(() => vi.fn()), onTabs: vi.fn(), onScopeSuspended: vi.fn(), + restoreScope: vi.fn(async (scopeId: string) => ({ + scopeId, + tabs: [], + activeTerminalId: null, + })), setTabs: vi.fn(), nativeSuspendScope: vi.fn(async () => true), + nativeSwitchTerminal: vi.fn(async () => {}), write: vi.fn(), })) +const bridgeTerminal = vi.hoisted(() => ({}) as Record) +Object.assign(bridgeTerminal, { + activateScope, + closeTerminal: vi.fn(), + clearScrollback, + dispose: vi.fn(), + disposeScope, + executeTool: vi.fn(), + getScrollback: vi.fn(), + getTabs, + migrateScope: nativeMigrateScope, + onCommand, + onData, + onDefaultZoomChanged, + onShortcutCommand, + onTabs, + onScopeSuspended, + openTerminal: nativeOpenTerminal, + reorderTerminal: nativeReorderTerminal, + resize: vi.fn(), + restoreScope, + switchTerminal: nativeSwitchTerminal, + suspendScope: nativeSuspendScope, + write, +}) + vi.mock('@/lib/desktop', () => ({ - getDesktopBridge: () => ({ - terminal: { - activateScope, - closeTerminal: vi.fn(), - clearScrollback, - dispose: vi.fn(), - disposeScope, - executeTool: vi.fn(), - getScrollback: vi.fn(), - getTabs, - migrateScope: nativeMigrateScope, - onCommand, - onData, - onDefaultZoomChanged, - onShortcutCommand, - onTabs, - onScopeSuspended, - openTerminal: vi.fn(), - reorderTerminal: nativeReorderTerminal, - resize: vi.fn(), - start: vi.fn(), - switchTerminal: vi.fn(), - suspendScope: nativeSuspendScope, - write, - }, - }), + getDesktopBridge: () => ({ terminal: bridgeTerminal }), isTerminalEnabled: () => true, })) @@ -85,6 +104,8 @@ vi.mock('@/stores/copilot-terminal/store', () => ({ useCopilotTerminalStore: { getState: () => ({ activeScopeId: null, + sessions: {}, + activateScope: activateStoreScope, applyCommandEvent, discardScope, migrateScope: migrateStoreScope, @@ -95,6 +116,7 @@ vi.mock('@/stores/copilot-terminal/store', () => ({ })) import { + activateTerminalScope, clearTerminalScrollback, discardTerminalScope, initTerminalTransport, @@ -102,8 +124,10 @@ import { onTerminalData, onTerminalDefaultZoomChanged, onTerminalShortcutCommand, + openTerminal, reorderTerminal, suspendTerminalScope, + switchTerminal, writeToTerminal, } from '@/lib/terminal/transport' @@ -124,9 +148,101 @@ describe('terminal transport chat scopes', () => { migrateStoreScope.mockClear() nativeMigrateScope.mockReset() nativeReorderTerminal.mockReset() + activateScope.mockClear() + restoreScope.mockClear() + nativeSwitchTerminal.mockClear() + nativeOpenTerminal.mockClear() + nativeStart.mockClear() write.mockClear() }) + it('restores a chat with no live shells when its scope is activated', async () => { + restoreScope.mockResolvedValueOnce({ + scopeId: 'chat-restore', + tabs: [ + { + terminalId: 'restored-1', + title: 'sim', + cwd: '/code/sim', + running: null, + interactive: false, + active: true, + }, + ], + activeTerminalId: 'restored-1', + }) + + await activateTerminalScope('chat-restore') + + expect(restoreScope).toHaveBeenCalledWith('chat-restore') + expect(setTabs).toHaveBeenLastCalledWith( + expect.objectContaining({ scopeId: 'chat-restore', activeTerminalId: 'restored-1' }) + ) + }) + + it('does not restore when the chat already has live shells', async () => { + activateScope.mockResolvedValueOnce({ + scopeId: 'chat-live', + tabs: [ + { + terminalId: 'live-1', + title: 'sim', + cwd: '/code/sim', + running: null, + interactive: false, + active: true, + }, + ], + activeTerminalId: 'live-1', + }) + + await activateTerminalScope('chat-live') + + expect(restoreScope).not.toHaveBeenCalled() + }) + + it('skips the restore when the user moved to another chat during activation', async () => { + let finishActivation: (tabs: ScopedTerminalTabsState) => void = () => {} + activateScope.mockImplementationOnce( + () => new Promise((resolve) => (finishActivation = resolve)) + ) + + const first = activateTerminalScope('chat-first') + await activateTerminalScope('chat-second') + finishActivation({ scopeId: 'chat-first', tabs: [], activeTerminalId: null }) + await first + + expect(restoreScope).toHaveBeenCalledExactlyOnceWith('chat-second') + }) + + it('opens a fresh shell through openTerminal on shells that restore on activation', async () => { + await openTerminal(undefined, 'chat-b') + + expect(nativeOpenTerminal).toHaveBeenCalledWith(undefined, 'chat-b') + expect(nativeStart).not.toHaveBeenCalled() + }) + + it('adopts a chat through start on shells that cannot restore on activation', async () => { + const { restoreScope: modern } = bridgeTerminal + bridgeTerminal.restoreScope = undefined + bridgeTerminal.start = nativeStart + try { + await openTerminal(undefined, 'chat-b') + } finally { + bridgeTerminal.restoreScope = modern + bridgeTerminal.start = undefined + } + + expect(nativeStart).toHaveBeenCalledWith({ cols: 80, rows: 24 }, 'chat-b') + expect(nativeOpenTerminal).not.toHaveBeenCalled() + }) + + it('forwards a terminal switch with its claim option', async () => { + await switchTerminal('terminal-b', 'chat-b', { claim: false }) + + expect(nativeSwitchTerminal).toHaveBeenCalledWith('terminal-b', 'chat-b', { claim: false }) + }) + it('routes pushed tab and command state to the scope carried by each event', () => { const tabsListener = onTabs.mock.calls[0][0] as (state: ScopedTerminalTabsState) => void const commandListener = onCommand.mock.calls[0][0] as ( diff --git a/apps/sim/lib/terminal/transport.ts b/apps/sim/lib/terminal/transport.ts index b8ffa1aa29e..f2435b1668f 100644 --- a/apps/sim/lib/terminal/transport.ts +++ b/apps/sim/lib/terminal/transport.ts @@ -19,7 +19,6 @@ import { import type { ScopedTerminalTabsState, TerminalOperation, - TerminalStartOptions, TerminalToolArgs, } from '@sim/terminal-protocol' import { getDesktopBridge, isTerminalEnabled } from '@/lib/desktop' @@ -68,14 +67,22 @@ export function initTerminalTransport(): void { terminal.onScopeSuspended(applyTerminalScopeSuspended) } -/** Makes one chat's terminal group active in both renderer and desktop. */ +/** + * Makes one chat's terminal group active in both renderer and desktop, then + * materializes its saved shells. Each live shell is a resource tab, so the + * tab list has to exist before any terminal panel is mounted. + */ export async function activateTerminalScope(scopeId: string): Promise { activeScopeId = scopeId useCopilotTerminalStore.getState().activateScope(scopeId) const terminal = bridge() if (!terminal) return const tabs = await terminal.activateScope(scopeId) + if (tabs.scopeId !== scopeId) return useCopilotTerminalStore.getState().setTabs(tabs) + if (tabs.tabs.length > 0 || activeScopeId !== scopeId || !terminal.restoreScope) return + const restored = await terminal.restoreScope(scopeId) + if (restored.scopeId === scopeId) useCopilotTerminalStore.getState().setTabs(restored) } /** Rebinds a pending new-chat terminal group to the chat id assigned by the server. */ @@ -212,17 +219,6 @@ export async function clearTerminalScrollback( return (await bridge()?.clearScrollback(terminalId, scopeId)) ?? false } -export async function startTerminalSession( - options: TerminalStartOptions, - scopeId = currentTerminalScopeId() -): Promise { - const terminal = bridge() - if (!terminal) { - throw new Error('The Sim desktop terminal is unavailable.') - } - return terminal.start(options, scopeId) -} - export function writeToTerminal( terminalId: string, data: string, @@ -261,14 +257,22 @@ export async function openTerminal( ): Promise { const terminal = bridge() if (!terminal) throw new Error('The Sim desktop terminal is unavailable.') + const live = useCopilotTerminalStore.getState().sessions[scopeId]?.tabs.tabs.length ?? 0 + // A shell without `restoreScope` only applies a chat's saved shells through + // `start`; opening a fresh one first would overwrite that saved set. + if (!terminal.restoreScope && terminal.start && live === 0 && cwd === undefined) { + return terminal.start({ cols: 80, rows: 24 }, scopeId) + } return terminal.openTerminal(cwd, scopeId) } +/** Shows a terminal; `claim: false` mirrors a strip selection without claiming the shell. */ export async function switchTerminal( terminalId: string, - scopeId = currentTerminalScopeId() + scopeId = currentTerminalScopeId(), + options?: { claim?: boolean } ): Promise { - await bridge()?.switchTerminal(terminalId, scopeId) + await bridge()?.switchTerminal(terminalId, scopeId, options) } /** Moves a terminal tab when the installed shell supports ordering. */ diff --git a/apps/sim/lib/workspaces/create.test.ts b/apps/sim/lib/workspaces/create.test.ts index 771740f8fca..af282f9c778 100644 --- a/apps/sim/lib/workspaces/create.test.ts +++ b/apps/sim/lib/workspaces/create.test.ts @@ -47,7 +47,7 @@ import { createDefaultPersonalWorkspaceInTransaction, createWorkspace, } from '@/lib/workspaces/create' -import { WORKSPACE_MODE } from '@/lib/workspaces/policy' +import { WORKSPACE_MODE, WorkspaceOwnerMissingError } from '@/lib/workspaces/policy' const params = { userId: 'creator-1', @@ -127,6 +127,34 @@ describe('createWorkspace capability-gate placement', () => { }) }) + /** + * A cached session cookie can outlive the user row by a few minutes. The + * insert then fails on a `workspace` -> `user` foreign key, which the caller + * must be able to tell apart from a fault so it answers 401, not 500. + */ + it('reports a missing owner as a typed error instead of a fault', async () => { + mockResolveGoverningPermissionGroupOrganization.mockResolvedValue('org-1') + dbChainMockFns.transaction.mockRejectedValue( + Object.assign(new Error('insert or update on table "workspace" violates foreign key'), { + code: '23503', + constraint_name: 'workspace_billed_account_user_id_user_id_fk', + }) + ) + + await expect(createWorkspace(params)).rejects.toBeInstanceOf(WorkspaceOwnerMissingError) + }) + + it('rethrows other foreign key violations untouched', async () => { + mockResolveGoverningPermissionGroupOrganization.mockResolvedValue('org-1') + const failure = Object.assign(new Error('violates foreign key'), { + code: '23503', + constraint_name: 'workspace_organization_id_organization_id_fk', + }) + dbChainMockFns.transaction.mockRejectedValue(failure) + + await expect(createWorkspace(params)).rejects.toBe(failure) + }) + /** * The preflight policy resolved this value microseconds earlier in the same * request, and React's `cache()` memo does not span the two calls, so a diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index 44d4e8e77b3..8e5d8f46c0b 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { PlatformEvents } from '@/lib/core/telemetry' import type { DbOrTx } from '@/lib/db/types' @@ -12,8 +13,15 @@ import { resolveGoverningPermissionGroupOrganization, resolveInviteFlags, WORKSPACE_MODE, + WorkspaceOwnerMissingError, } from '@/lib/workspaces/policy' +/** Foreign keys from `workspace` to `user`; a violation means the acting user's row is gone. */ +const WORKSPACE_USER_FK_CONSTRAINTS = new Set([ + 'workspace_owner_id_user_id_fk', + 'workspace_billed_account_user_id_user_id_fk', +]) + const logger = createLogger('WorkspaceCreate') export interface CreateWorkspaceParams { @@ -208,6 +216,13 @@ export async function createWorkspace(params: CreateWorkspaceParams) { createWorkspaceInTransaction(tx, { ...params, governingPermissionGroupOrganizationId }) ) } catch (error) { + if ( + getPostgresErrorCode(error) === '23503' && + WORKSPACE_USER_FK_CONSTRAINTS.has(getPostgresConstraintName(error) ?? '') + ) { + logger.warn('Workspace creation raced account deletion', { userId: params.userId }) + throw new WorkspaceOwnerMissingError(params.userId) + } logger.error('Failed to create workspace', { userId: params.userId, error }) throw error } diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 3b511b15945..42c2e336eb1 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -111,6 +111,18 @@ export interface WorkspaceCreationPolicy { blockedReasonCode?: 'organization-subscription-inactive' | 'permission-group-denied' } +/** + * The acting user's row is gone, so no workspace can reference it. Reached + * when a request still carrying a cached session cookie arrives after the + * account was deleted; the caller should answer as unauthenticated. + */ +export class WorkspaceOwnerMissingError extends Error { + constructor(userId: string) { + super(`User ${userId} no longer exists`) + this.name = 'WorkspaceOwnerMissingError' + } +} + export class WorkspaceCreationContextChangedError extends Error { constructor(message = 'Workspace creation context changed before the workspace was inserted') { super(message) diff --git a/apps/sim/package.json b/apps/sim/package.json index 35600921107..aab01234f88 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -20,7 +20,6 @@ "build": "bun run build:sandbox-bundles && NODE_OPTIONS='--max-old-space-size=8192' next build", "build:sandbox-bundles": "bun run ./lib/execution/sandbox/bundles/build.ts", "start": "next start", - "prepare": "cd ../.. && bun husky", "test": "vitest run", "test:scim:e2e": "bun run scripts/test-scim-e2e.ts", "test:watch": "vitest", @@ -175,6 +174,7 @@ "csv-parse": "7.0.2", "date-fns": "4.1.0", "decimal.js": "10.6.0", + "diff": "8.0.4", "docx-preview": "^0.3.7", "docx": "^9.6.1", "drizzle-orm": "^0.45.2", diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 3a85a478257..b1dc2145a2e 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -130,6 +130,7 @@ describe('resolveApiCorsPolicy', () => { expect(policy.credentials).toBe(false) expect(policy.headers).toContain('X-Run-Id') expect(policy.headers).toContain('X-Sim-Stream-Protocol') + expect(policy.headers).toContain('X-Sim-Client-Info') expect(policy.headers).toContain('Authorization') expect(policy.headers).not.toContain('X-Execution-Id') // Async is body-selected on v2 — the mode header is deliberately absent. diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 2bce1dd5f6d..24d09a30419 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -49,15 +49,39 @@ const DEFAULT_API_ALLOWED_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS' const DEFAULT_API_EXPOSED_HEADERS = 'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' -const DEFAULT_API_ALLOWED_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization' +/** + * Every API policy allows these. `X-Sim-Client-Info` is here rather than on one + * policy because every official client sends it on every request. + */ +const BASE_API_ALLOWED_HEADERS = [ + 'X-CSRF-Token', + 'X-Requested-With', + 'Accept', + 'Accept-Version', + 'Content-Length', + 'Content-MD5', + 'Content-Type', + 'Date', + 'X-Api-Version', + 'X-API-Key', + 'Authorization', + 'X-Sim-Client-Info', +] as const + +function allowedHeaders(...extra: string[]): string { + return [...BASE_API_ALLOWED_HEADERS, ...extra].join(', ') +} + +const DEFAULT_API_ALLOWED_HEADERS = allowedHeaders() -const WORKFLOW_EXECUTE_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, X-Execution-Id, X-Execution-Mode, X-Execution-Timeout-Seconds' +const WORKFLOW_EXECUTE_HEADERS = allowedHeaders( + 'X-Execution-Id', + 'X-Execution-Mode', + 'X-Execution-Timeout-Seconds' +) /** v2 execute: run identity and modes use the v2 wire names while streaming negotiates its protocol. */ -const WORKFLOW_EXECUTE_V2_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, X-Run-Id, X-Sim-Stream-Protocol' +const WORKFLOW_EXECUTE_V2_HEADERS = allowedHeaders('X-Run-Id', 'X-Sim-Stream-Protocol') /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) diff --git a/apps/sim/public/library/ai-coding-agents-vs-ai-workflow-agents/cover.jpg b/apps/sim/public/library/ai-coding-agents-vs-ai-workflow-agents/cover.jpg new file mode 100644 index 00000000000..21dd96f9337 Binary files /dev/null and b/apps/sim/public/library/ai-coding-agents-vs-ai-workflow-agents/cover.jpg differ diff --git a/apps/sim/public/library/top-ai-assistants-2026/cover.jpg b/apps/sim/public/library/top-ai-assistants-2026/cover.jpg new file mode 100644 index 00000000000..86c1c564fbc Binary files /dev/null and b/apps/sim/public/library/top-ai-assistants-2026/cover.jpg differ diff --git a/apps/sim/scripts/register-platform-slack-app.ts b/apps/sim/scripts/register-platform-slack-app.ts index 55ffbd21cf6..5e0376cbb09 100644 --- a/apps/sim/scripts/register-platform-slack-app.ts +++ b/apps/sim/scripts/register-platform-slack-app.ts @@ -10,12 +10,17 @@ const logger = createLogger('RegisterPlatformSlackApp') /** Explicit deployment preparation; never chooses an app identity from an unauthenticated event. */ async function main() { const appId = process.argv[2] - const clientId = process.env.SLACK_CLIENT_ID - const clientSecret = process.env.SLACK_CLIENT_SECRET - const signingSecret = process.env.SLACK_SIGNING_SECRET + const searchApp = process.argv.includes('--search') + const clientId = searchApp ? process.env.SLACK_SEARCH_CLIENT_ID : process.env.SLACK_CLIENT_ID + const clientSecret = searchApp + ? process.env.SLACK_SEARCH_CLIENT_SECRET + : process.env.SLACK_CLIENT_SECRET + const signingSecret = searchApp + ? process.env.SLACK_SEARCH_SIGNING_SECRET + : process.env.SLACK_SIGNING_SECRET if (!appId || !/^A[A-Z0-9]+$/.test(appId) || !clientId || !clientSecret || !signingSecret) throw new Error( - 'Supply the verified platform Slack app ID and SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET' + 'Supply a verified app ID and client/signing secrets. With --search use SLACK_SEARCH_CLIENT_ID, SLACK_SEARCH_CLIENT_SECRET, SLACK_SEARCH_SIGNING_SECRET; otherwise use SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET.' ) const [client, signing] = await Promise.all([ encryptSecret(clientSecret), diff --git a/apps/sim/stores/browser-session/store.test.ts b/apps/sim/stores/browser-session/store.test.ts index 50efa8e2efa..467dc9ed14a 100644 --- a/apps/sim/stores/browser-session/store.test.ts +++ b/apps/sim/stores/browser-session/store.test.ts @@ -33,7 +33,6 @@ describe('browser session store', () => { url: 'https://docs.sim.ai', loading: false, active: false, - pinned: false, }, { tabId: '2', @@ -41,7 +40,6 @@ describe('browser session store', () => { url: 'https://sim.ai/workspace', loading: true, active: true, - pinned: false, }, ], }) @@ -193,38 +191,6 @@ describe('browser session store', () => { expect(getBrowserSession('chat-test').pageState?.sitePermissionRequest).toBeUndefined() }) - it('reorders tabs optimistically without changing the active page', () => { - const store = useBrowserSessionStore.getState() - store.setTabsState({ - scopeId: 'chat-test', - activeTabId: '2', - tabs: [ - { - tabId: '1', - title: 'One', - url: 'https://one.example', - loading: false, - active: false, - pinned: false, - }, - { - tabId: '2', - title: 'Two', - url: 'https://two.example', - loading: false, - active: true, - pinned: false, - }, - ], - }) - - store.reorderTab('chat-test', '2', 0) - - expect(getBrowserSession('chat-test').tabs.map((tab) => tab.tabId)).toEqual(['2', '1']) - expect(getBrowserSession('chat-test').activeTabId).toBe('2') - expect(getBrowserSession('chat-test').pageState?.tabId).toBe('2') - }) - it('retains a settled tab title when opening a new tab pushes a temporary blank title', () => { const store = useBrowserSessionStore.getState() store.setTabsState({ @@ -237,7 +203,6 @@ describe('browser session store', () => { url: 'https://example.com/docs', loading: false, active: true, - pinned: false, }, ], }) @@ -264,7 +229,6 @@ describe('browser session store', () => { url: 'https://example.com/docs', loading: false, active: false, - pinned: false, }, { tabId: '2', @@ -272,7 +236,6 @@ describe('browser session store', () => { url: '', loading: false, active: true, - pinned: false, }, ], }) @@ -296,7 +259,6 @@ describe('browser session store', () => { url: 'https://a.example', loading: false, active: true, - pinned: false, }, ], }) @@ -312,7 +274,6 @@ describe('browser session store', () => { url: 'https://b.example', loading: false, active: true, - pinned: false, }, ], }) @@ -371,7 +332,6 @@ describe('browser session store', () => { url: 'https://example.com', loading: false, active: true, - pinned: false, }, ], }) @@ -420,7 +380,6 @@ describe('browser session store', () => { url: 'https://example.com', loading: false, active: true, - pinned: false, }, ], }) @@ -483,7 +442,6 @@ describe('browser session store', () => { url: 'https://a.example', loading: false, active: true, - pinned: false, }, ], }) @@ -499,7 +457,6 @@ describe('browser session store', () => { url: 'https://stale.example', loading: false, active: true, - pinned: false, }, ], }) @@ -529,7 +486,6 @@ describe('browser session store', () => { url: 'https://fresh.example', loading: false, active: true, - pinned: false, }, ], }) diff --git a/apps/sim/stores/browser-session/store.ts b/apps/sim/stores/browser-session/store.ts index dd69f9f8eae..d536e18424b 100644 --- a/apps/sim/stores/browser-session/store.ts +++ b/apps/sim/stores/browser-session/store.ts @@ -47,7 +47,6 @@ interface BrowserSessionState { runIds: readonly string[], options?: { hardResetScopeIds?: readonly string[] } ) => void - reorderTab: (scopeId: string, tabId: string, targetIndex: number) => void setSessionAlive: (alive: boolean, scopeId: string) => void } @@ -141,7 +140,6 @@ function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean { a.title === b.title && a.loading === b.loading && a.active === b.active && - a.pinned === b.pinned && pageIssueEqual(a.issue, b.issue) ) } @@ -429,22 +427,6 @@ export const useBrowserSessionStore = create()( ) return changed ? { sessions } : {} }), - reorderTab: (scopeId, tabId, targetIndex) => - set((state) => - withSession(state, scopeId, (current) => { - const currentIndex = current.tabs.findIndex((tab) => tab.tabId === tabId) - if (currentIndex < 0 || !Number.isFinite(targetIndex)) return current - const nextIndex = Math.max( - 0, - Math.min(current.tabs.length - 1, Math.trunc(targetIndex)) - ) - if (currentIndex === nextIndex) return current - const tabs = [...current.tabs] - const [tab] = tabs.splice(currentIndex, 1) - tabs.splice(nextIndex, 0, tab) - return { ...current, tabs } - }) - ), setSessionAlive: (alive, scopeId) => set((state) => { return withSession(state, scopeId, (current) => { diff --git a/apps/sim/tools/brex/idempotency.test.ts b/apps/sim/tools/brex/idempotency.test.ts index 328bfbe449b..3d224088258 100644 --- a/apps/sim/tools/brex/idempotency.test.ts +++ b/apps/sim/tools/brex/idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/apps/sim/tools/outlook/calendar-idempotency.test.ts b/apps/sim/tools/outlook/calendar-idempotency.test.ts index 24dd884ef86..76493a5d5d4 100644 --- a/apps/sim/tools/outlook/calendar-idempotency.test.ts +++ b/apps/sim/tools/outlook/calendar-idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/apps/sim/tools/square/idempotency.test.ts b/apps/sim/tools/square/idempotency.test.ts index 55093db3d0c..a649e8c0251 100644 --- a/apps/sim/tools/square/idempotency.test.ts +++ b/apps/sim/tools/square/idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/apps/sim/tools/stripe/idempotency.test.ts b/apps/sim/tools/stripe/idempotency.test.ts index d4c228fabe8..f4315955698 100644 --- a/apps/sim/tools/stripe/idempotency.test.ts +++ b/apps/sim/tools/stripe/idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/bun.lock b/bun.lock index 3b63e4d5571..848b03f4155 100644 --- a/bun.lock +++ b/bun.lock @@ -290,6 +290,7 @@ "csv-parse": "7.0.2", "date-fns": "4.1.0", "decimal.js": "10.6.0", + "diff": "8.0.4", "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", diff --git a/package.json b/package.json index 080c1582317..768902f6a06 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "test:workflow-sync": "bun --no-env-file scripts/test-workflow-sync.ts", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts", - "test:scripts": "vitest run --config vitest.scripts.config.ts" + "test:scripts": "vitest run --config scripts/vitest.config.ts" }, "overrides": { "react": "19.2.4", diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 201b238f891..17a0e0806e8 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -751,6 +751,27 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { } } +/** + * How a principal was authenticated, for request logs and analytics: its kind, + * plus the service behind a delegated or system principal and the OAuth client + * behind an access token. Identifiers that name a person, key, or token are + * deliberately left out — this describes the credential's kind, not the actor. + */ +export interface PrincipalAuthDescriptor { + kind: Principal['kind'] + service?: string + clientId?: string +} + +export function describePrincipalAuth(principal: Principal): PrincipalAuthDescriptor { + const actor = toPrincipalActor(principal) + return { + kind: actor.kind, + ...('serviceId' in actor ? { service: actor.serviceId } : {}), + ...('clientId' in actor ? { clientId: actor.clientId } : {}), + } +} + export function resolvePrincipalAuditAttribution(principal: Principal): PrincipalAuditAttribution { const actor = toPrincipalActor(principal) diff --git a/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts index 5096378a687..91195dc6a84 100644 --- a/packages/browser-protocol/src/index.ts +++ b/packages/browser-protocol/src/index.ts @@ -215,10 +215,11 @@ export interface BrowserPanelSnapshot { /** * Browser-chrome commands from the panel header (URL bar, back/forward, - * reload) plus the legacy `takeover-done` action retained for persisted - * `browser_request_takeover` cards. Page interactions need no protocol — the - * user acts on the real embedded page directly, and its right-click menu is - * native and lives entirely in the shell. + * reload), the resource tab strip (`switch-tab`, `close-tab`), plus the legacy + * `takeover-done` action retained for persisted `browser_request_takeover` + * cards. Page interactions need no protocol — the user acts on the real + * embedded page directly, and its right-click menu is native and lives + * entirely in the shell. */ export interface BrowserPanelAction { action: @@ -226,8 +227,8 @@ export interface BrowserPanelAction { | 'reload' | 'back' | 'forward' + /** Fallback for installed shells that predate the acknowledged `openTab` bridge call. */ | 'new-tab' - | 'duplicate-tab' | 'switch-tab' | 'close-tab' | 'print' @@ -239,8 +240,14 @@ export interface BrowserPanelAction { | 'takeover-done' /** Absolute URL for `navigate` (typed into the panel's URL bar). */ url?: string - /** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */ + /** Stable tab id for `switch-tab` and `close-tab`. */ tabId?: string + /** + * `switch-tab` only: false when the switch mirrors a selection made outside + * the page (the resource strip), so it must not count as the user claiming + * the page from the agent. Older shells treat every switch as a claim. + */ + claim?: boolean /** Optional free-text instruction submitted with `takeover-done`. */ takeoverResponse?: string /** Exact pending permission request being answered. */ @@ -351,8 +358,6 @@ export interface BrowserTabState { active: boolean /** Recoverable problem currently replacing this tab's native page surface. */ issue?: BrowserPageIssue - /** Pinned tabs are ordered before regular tabs and cannot be closed. */ - pinned: boolean } /** Complete live tab list pushed by the desktop shell. */ diff --git a/packages/db/migrations/0338_organization_search_activity.sql b/packages/db/migrations/0338_organization_search_activity.sql new file mode 100644 index 00000000000..54d5a2ee6f8 --- /dev/null +++ b/packages/db/migrations/0338_organization_search_activity.sql @@ -0,0 +1,16 @@ +CREATE TABLE "organization_search_invocation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text, + "surface" text NOT NULL, + "source_types" text[] NOT NULL, + "result_count" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "organization_search_invocation_result_count_bounds" CHECK ("organization_search_invocation"."result_count" BETWEEN 0 AND 100), + CONSTRAINT "organization_search_invocation_source_types_bounds" CHECK (cardinality("organization_search_invocation"."source_types") <= 100) +); +--> statement-breakpoint +ALTER TABLE "organization_search_invocation" ADD CONSTRAINT "organization_search_invocation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_search_invocation" ADD CONSTRAINT "organization_search_invocation_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "organization_search_invocation_org_created_idx" ON "organization_search_invocation" USING btree ("organization_id","created_at");--> statement-breakpoint +CREATE INDEX "organization_search_invocation_user_idx" ON "organization_search_invocation" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/migrations/0339_connector_permissions.sql b/packages/db/migrations/0339_connector_permissions.sql new file mode 100644 index 00000000000..cd2b9a9b95d --- /dev/null +++ b/packages/db/migrations/0339_connector_permissions.sql @@ -0,0 +1,21 @@ +-- migration-safe: Additive private connector permission tables only; existing connector and worker schemas remain unchanged. Deploy before compatible workers and application UI. +CREATE TABLE "knowledge_connector_permission_grant" ( + "connector_id" text NOT NULL, + "group_key" text NOT NULL, + "subject_token" text NOT NULL, + CONSTRAINT "kcpg_pk" PRIMARY KEY("connector_id","group_key","subject_token"), + CONSTRAINT "kcpg_group_check" CHECK (length("knowledge_connector_permission_grant"."group_key") BETWEEN 1 AND 255), + CONSTRAINT "kcpg_subject_check" CHECK ("knowledge_connector_permission_grant"."subject_token" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$') +); +--> statement-breakpoint +CREATE TABLE "knowledge_connector_permission_snapshot" ( + "connector_id" text PRIMARY KEY NOT NULL, + "revision" integer NOT NULL, + "metadata" jsonb NOT NULL, + "payload" jsonb NOT NULL, + CONSTRAINT "kcps_revision_check" CHECK ("knowledge_connector_permission_snapshot"."revision" > 0) +); +--> statement-breakpoint +ALTER TABLE "knowledge_connector_permission_grant" ADD CONSTRAINT "kcpg_snapshot_fk" FOREIGN KEY ("connector_id") REFERENCES "public"."knowledge_connector_permission_snapshot"("connector_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "knowledge_connector_permission_snapshot" ADD CONSTRAINT "kcps_connector_fk" FOREIGN KEY ("connector_id") REFERENCES "public"."knowledge_connector"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "kcpg_subject_idx" ON "knowledge_connector_permission_grant" USING btree ("subject_token","connector_id","group_key"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0338_snapshot.json b/packages/db/migrations/meta/0338_snapshot.json new file mode 100644 index 00000000000..04095d33534 --- /dev/null +++ b/packages/db/migrations/meta/0338_snapshot.json @@ -0,0 +1,26162 @@ +{ + "id": "980173df-14ec-401b-89a6-53d1999bc12d", + "prevId": "52816097-17cd-473b-975a-f7b08169445d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_recovery_idx": { + "name": "doc_processing_recovery_idx", + "columns": [ + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_idx": { + "name": "doc_connector_reconciliation_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"source_seen_at\", '-infinity'::timestamp)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_384_vector_hnsw_idx": { + "name": "embedding_384_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_768_vector_hnsw_idx": { + "name": "embedding_768_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_1024_vector_hnsw_idx": { + "name": "embedding_1024_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_3072_vector_hnsw_idx": { + "name": "embedding_3072_vector_hnsw_idx", + "columns": [ + { + "expression": "(\"embedding_3072\"::halfvec(3072)) halfvec_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_invocation": { + "name": "organization_search_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_types": { + "name": "source_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_invocation_org_created_idx": { + "name": "organization_search_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_invocation_user_idx": { + "name": "organization_search_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_invocation_organization_id_organization_id_fk": { + "name": "organization_search_invocation_organization_id_organization_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_invocation_user_id_user_id_fk": { + "name": "organization_search_invocation_user_id_user_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_invocation_result_count_bounds": { + "name": "organization_search_invocation_result_count_bounds", + "value": "\"organization_search_invocation\".\"result_count\" BETWEEN 0 AND 100" + }, + "organization_search_invocation_source_types_bounds": { + "name": "organization_search_invocation_source_types_bounds", + "value": "cardinality(\"organization_search_invocation\".\"source_types\") <= 100" + } + }, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox_event\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_org_domain_unique": { + "name": "sso_provider_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(regexp_replace(btrim(\"domain\"), '^\\*\\.', ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sso_provider\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0339_snapshot.json b/packages/db/migrations/meta/0339_snapshot.json new file mode 100644 index 00000000000..dd5dd20b27c --- /dev/null +++ b/packages/db/migrations/meta/0339_snapshot.json @@ -0,0 +1,26297 @@ +{ + "id": "1bdee29d-c55a-4de9-a0a3-03a6c6aa68aa", + "prevId": "980173df-14ec-401b-89a6-53d1999bc12d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_recovery_idx": { + "name": "doc_processing_recovery_idx", + "columns": [ + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_idx": { + "name": "doc_connector_reconciliation_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"source_seen_at\", '-infinity'::timestamp)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_384_vector_hnsw_idx": { + "name": "embedding_384_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_768_vector_hnsw_idx": { + "name": "embedding_768_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_1024_vector_hnsw_idx": { + "name": "embedding_1024_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_3072_vector_hnsw_idx": { + "name": "embedding_3072_vector_hnsw_idx", + "columns": [ + { + "expression": "(\"embedding_3072\"::halfvec(3072)) halfvec_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_grant": { + "name": "knowledge_connector_permission_grant", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kcpg_subject_idx": { + "name": "kcpg_subject_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kcpg_snapshot_fk": { + "name": "kcpg_snapshot_fk", + "tableFrom": "knowledge_connector_permission_grant", + "tableTo": "knowledge_connector_permission_snapshot", + "columnsFrom": ["connector_id"], + "columnsTo": ["connector_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcpg_pk": { + "name": "kcpg_pk", + "columns": ["connector_id", "group_key", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcpg_group_check": { + "name": "kcpg_group_check", + "value": "length(\"knowledge_connector_permission_grant\".\"group_key\") BETWEEN 1 AND 255" + }, + "kcpg_subject_check": { + "name": "kcpg_subject_check", + "value": "\"knowledge_connector_permission_grant\".\"subject_token\" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_snapshot": { + "name": "knowledge_connector_permission_snapshot", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "kcps_connector_fk": { + "name": "kcps_connector_fk", + "tableFrom": "knowledge_connector_permission_snapshot", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcps_revision_check": { + "name": "kcps_revision_check", + "value": "\"knowledge_connector_permission_snapshot\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_invocation": { + "name": "organization_search_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_types": { + "name": "source_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_invocation_org_created_idx": { + "name": "organization_search_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_invocation_user_idx": { + "name": "organization_search_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_invocation_organization_id_organization_id_fk": { + "name": "organization_search_invocation_organization_id_organization_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_invocation_user_id_user_id_fk": { + "name": "organization_search_invocation_user_id_user_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_invocation_result_count_bounds": { + "name": "organization_search_invocation_result_count_bounds", + "value": "\"organization_search_invocation\".\"result_count\" BETWEEN 0 AND 100" + }, + "organization_search_invocation_source_types_bounds": { + "name": "organization_search_invocation_source_types_bounds", + "value": "cardinality(\"organization_search_invocation\".\"source_types\") <= 100" + } + }, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox_event\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_org_domain_unique": { + "name": "sso_provider_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(regexp_replace(btrim(\"domain\"), '^\\*\\.', ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sso_provider\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 660164f99d0..de9faaffd08 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2360,6 +2360,20 @@ "when": 1788986020651, "tag": "0337_colossal_the_renegades", "breakpoints": true + }, + { + "idx": 338, + "version": "7", + "when": 1789076658450, + "tag": "0338_organization_search_activity", + "breakpoints": true + }, + { + "idx": 339, + "version": "7", + "when": 1789084655493, + "tag": "0339_connector_permissions", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 89375ccdb66..d8d8342ebe2 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4550,6 +4550,37 @@ export const usageLogSourceEnum = pgEnum('usage_log_source', [ 'api-tool', ]) +/** Content-free organization Search activity, independent of billable model usage. */ +export const organizationSearchInvocation = pgTable( + 'organization_search_invocation', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + surface: text('surface').notNull(), + sourceTypes: text('source_types').array().notNull(), + resultCount: integer('result_count').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + organizationCreatedAtIdx: index('organization_search_invocation_org_created_idx').on( + table.organizationId, + table.createdAt + ), + userIdIdx: index('organization_search_invocation_user_idx').on(table.userId), + resultCountBounds: check( + 'organization_search_invocation_result_count_bounds', + sql`${table.resultCount} BETWEEN 0 AND 100` + ), + sourceTypesBounds: check( + 'organization_search_invocation_source_types_bounds', + sql`cardinality(${table.sourceTypes}) <= 100` + ), + }) +) + export const usageLog = pgTable( 'usage_log', { @@ -5500,6 +5531,52 @@ export const knowledgeConnector = pgTable( }) ) +/** Private provider configuration; metadata reads never materialize the larger normalized payload. */ +export const knowledgeConnectorPermissionSnapshot = pgTable( + 'knowledge_connector_permission_snapshot', + { + connectorId: text('connector_id').primaryKey(), + revision: integer('revision').notNull(), + metadata: jsonb('metadata').$type>().notNull(), + payload: jsonb('payload').$type>().notNull(), + }, + (table) => ({ + revisionCheck: check('kcps_revision_check', sql`${table.revision} > 0`), + connectorFk: foreignKey({ + name: 'kcps_connector_fk', + columns: [table.connectorId], + foreignColumns: [knowledgeConnector.id], + }).onDelete('cascade'), + }) +) + +/** Administrator-managed connector groups; provider directory crawls never write these grants. */ +export const knowledgeConnectorPermissionGrant = pgTable( + 'knowledge_connector_permission_grant', + { + connectorId: text('connector_id').notNull(), + groupKey: text('group_key').notNull(), + subjectToken: text('subject_token').notNull(), + }, + (table) => ({ + pk: primaryKey({ + name: 'kcpg_pk', + columns: [table.connectorId, table.groupKey, table.subjectToken], + }), + subjectIdx: index('kcpg_subject_idx').on(table.subjectToken, table.connectorId, table.groupKey), + snapshotFk: foreignKey({ + name: 'kcpg_snapshot_fk', + columns: [table.connectorId], + foreignColumns: [knowledgeConnectorPermissionSnapshot.connectorId], + }).onDelete('cascade'), + groupCheck: check('kcpg_group_check', sql`length(${table.groupKey}) BETWEEN 1 AND 255`), + subjectCheck: check( + 'kcpg_subject_check', + sql`${table.subjectToken} ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'` + ), + }) +) + /** * One row per (members-mode connector, member credential). Membership is * derived from the credential-group option on every run: `active` while the diff --git a/packages/desktop-bridge/contract-snapshot.ts b/packages/desktop-bridge/contract-snapshot.ts index e044e056469..f81d290db5f 100644 --- a/packages/desktop-bridge/contract-snapshot.ts +++ b/packages/desktop-bridge/contract-snapshot.ts @@ -40,25 +40,31 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ 'browser_open_url', 'browser_go_back', 'browser_go_forward', + 'browser_reload', 'browser_open_tab', 'browser_switch_tab', 'browser_close_tab', 'browser_list_tabs', 'browser_list_sessions', + 'browser_list_downloads', 'browser_wait_for', 'browser_snapshot', + 'browser_find', 'browser_read_text', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_click_at', 'browser_type', + 'browser_fill_form', 'browser_insert_text', 'browser_press_key', 'browser_scroll', 'browser_select_option', + 'browser_set_checked', 'browser_hover', 'browser_drag', + 'browser_zoom', ] as const export type CurrentBrowserToolName = (typeof CURRENT_BROWSER_TOOL_NAMES)[number] @@ -100,6 +106,31 @@ export function normalizeBrowserWaitForTimeoutMs(value: unknown): number { return Math.min(parsed, BROWSER_WAIT_FOR_MAX_TIMEOUT_MS) } +/** Client execution budget, including authorization, native queueing, and result delivery. */ +export function browserToolRendererTimeoutMs( + tool: CurrentBrowserToolName, + params: Record = {} +): number { + switch (tool) { + case 'browser_navigate': + case 'browser_open_url': + case 'browser_go_back': + case 'browser_go_forward': + case 'browser_reload': + case 'browser_open_tab': + case 'browser_switch_tab': + return BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS + case 'browser_wait_for': + return ( + BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + + normalizeBrowserWaitForTimeoutMs(params.timeoutMs) + + BROWSER_WAIT_FOR_RENDERER_GRACE_MS + ) + default: + return BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + 30_000 + } +} + export const BROWSER_THEMES = ['system', 'light', 'dark'] as const /** Sim appearance preference mirrored into browser-tab media queries. */ @@ -199,10 +230,11 @@ export interface BrowserPanelSnapshot { /** * Browser-chrome commands from the panel header (URL bar, back/forward, - * reload) plus the legacy `takeover-done` action retained for persisted - * `browser_request_takeover` cards. Page interactions need no protocol — the - * user acts on the real embedded page directly, and its right-click menu is - * native and lives entirely in the shell. + * reload), the resource tab strip (`switch-tab`, `close-tab`), plus the legacy + * `takeover-done` action retained for persisted `browser_request_takeover` + * cards. Page interactions need no protocol — the user acts on the real + * embedded page directly, and its right-click menu is native and lives + * entirely in the shell. */ export interface BrowserPanelAction { action: @@ -210,8 +242,8 @@ export interface BrowserPanelAction { | 'reload' | 'back' | 'forward' + /** Fallback for installed shells that predate the acknowledged `openTab` bridge call. */ | 'new-tab' - | 'duplicate-tab' | 'switch-tab' | 'close-tab' | 'print' @@ -223,8 +255,14 @@ export interface BrowserPanelAction { | 'takeover-done' /** Absolute URL for `navigate` (typed into the panel's URL bar). */ url?: string - /** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */ + /** Stable tab id for `switch-tab` and `close-tab`. */ tabId?: string + /** + * `switch-tab` only: false when the switch mirrors a selection made outside + * the page (the resource strip), so it must not count as the user claiming + * the page from the agent. Older shells treat every switch as a claim. + */ + claim?: boolean /** Optional free-text instruction submitted with `takeover-done`. */ takeoverResponse?: string /** Exact pending permission request being answered. */ @@ -335,8 +373,6 @@ export interface BrowserTabState { active: boolean /** Recoverable problem currently replacing this tab's native page surface. */ issue?: BrowserPageIssue - /** Pinned tabs are ordered before regular tabs and cannot be closed. */ - pinned: boolean } /** Complete live tab list pushed by the desktop shell. */ @@ -923,8 +959,18 @@ export function isPendingDesktopScopeId(scopeId: string): boolean { * environment stay consistent between the two. */ export interface SimDesktopTerminalApi { - /** Open the first terminal, or adopt the ones already running. */ - start(options: TerminalStartOptions, scopeId: string): Promise + /** + * Materializes a chat's saved shells without opening one for a chat that + * had none. Optional for compatibility with installed shells that only + * restored when the terminal panel started. + */ + restoreScope?(scopeId: string): Promise + /** + * Opens the first terminal, or adopts the chat's saved shells. Only shells + * without {@link restoreScope} still expose it; newer ones restore on + * activation and open shells one at a time. + */ + start?(options: TerminalStartOptions, scopeId: string): Promise /** * Execute one terminal operation. Resolves with the outcome; never rejects * for tool-level failures (those ride `ok: false`). @@ -950,7 +996,15 @@ export interface SimDesktopTerminalApi { resize(terminalId: string, cols: number, rows: number, scopeId: string): void /** Open an additional terminal and make it active. */ openTerminal(cwd: string | undefined, scopeId: string): Promise - switchTerminal(terminalId: string, scopeId: string): Promise + /** + * Show a terminal. `claim: false` mirrors a resource-strip selection without + * recording the shell as the user's own; older shells treat every switch as a claim. + */ + switchTerminal( + terminalId: string, + scopeId: string, + options?: { claim?: boolean } + ): Promise /** Move a terminal to its final position. Optional for older installed shells. */ reorderTerminal?( terminalId: string, @@ -1049,12 +1103,11 @@ export interface SimDesktopBrowserAgentApi { disposeScope(scopeId: string): Promise /** Closes a soft-deleted chat's live pages while retaining its restart descriptor. */ suspendScope(scopeId: string): Promise - /** Pin or unpin a live browser tab. */ - setTabPinned(tabId: string, pinned: boolean, scopeId: string): void - /** Opens the native tab actions menu without covering the embedded page. */ - showTabContextMenu(tabId: string, scopeId: string): void - /** Move a live tab to a final list index. */ - reorderTab(tabId: string, targetIndex: number, scopeId: string): void + /** + * Move a live tab to a final list index, mirroring the resource strip. + * Optional for compatibility with installed shells that predate strip-owned order. + */ + reorderTab?(tabId: string, targetIndex: number, scopeId: string): void /** * Report where the browser panel sits in the window (CSS pixels relative * to the viewport), or null when the panel is hidden/unmounted. The main diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index cb8dc0926bf..f91713aec1e 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -51,8 +51,18 @@ export function isPendingDesktopScopeId(scopeId: string): boolean { * environment stay consistent between the two. */ export interface SimDesktopTerminalApi { - /** Open the first terminal, or adopt the ones already running. */ - start(options: TerminalStartOptions, scopeId: string): Promise + /** + * Materializes a chat's saved shells without opening one for a chat that + * had none. Optional for compatibility with installed shells that only + * restored when the terminal panel started. + */ + restoreScope?(scopeId: string): Promise + /** + * Opens the first terminal, or adopts the chat's saved shells. Only shells + * without {@link restoreScope} still expose it; newer ones restore on + * activation and open shells one at a time. + */ + start?(options: TerminalStartOptions, scopeId: string): Promise /** * Execute one terminal operation. Resolves with the outcome; never rejects * for tool-level failures (those ride `ok: false`). @@ -78,7 +88,15 @@ export interface SimDesktopTerminalApi { resize(terminalId: string, cols: number, rows: number, scopeId: string): void /** Open an additional terminal and make it active. */ openTerminal(cwd: string | undefined, scopeId: string): Promise - switchTerminal(terminalId: string, scopeId: string): Promise + /** + * Show a terminal. `claim: false` mirrors a resource-strip selection without + * recording the shell as the user's own; older shells treat every switch as a claim. + */ + switchTerminal( + terminalId: string, + scopeId: string, + options?: { claim?: boolean } + ): Promise /** Move a terminal to its final position. Optional for older installed shells. */ reorderTerminal?( terminalId: string, @@ -177,12 +195,11 @@ export interface SimDesktopBrowserAgentApi { disposeScope(scopeId: string): Promise /** Closes a soft-deleted chat's live pages while retaining its restart descriptor. */ suspendScope(scopeId: string): Promise - /** Pin or unpin a live browser tab. */ - setTabPinned(tabId: string, pinned: boolean, scopeId: string): void - /** Opens the native tab actions menu without covering the embedded page. */ - showTabContextMenu(tabId: string, scopeId: string): void - /** Move a live tab to a final list index. */ - reorderTab(tabId: string, targetIndex: number, scopeId: string): void + /** + * Move a live tab to a final list index, mirroring the resource strip. + * Optional for compatibility with installed shells that predate strip-owned order. + */ + reorderTab?(tabId: string, targetIndex: number, scopeId: string): void /** * Report where the browser panel sits in the window (CSS pixels relative * to the viewport), or null when the panel is hidden/unmounted. The main diff --git a/packages/emcn/src/components/button-group/button-group.tsx b/packages/emcn/src/components/button-group/button-group.tsx deleted file mode 100644 index 50d3d8c3b3e..00000000000 --- a/packages/emcn/src/components/button-group/button-group.tsx +++ /dev/null @@ -1,173 +0,0 @@ -'use client' - -import { - Children, - cloneElement, - createContext, - type HTMLAttributes, - isValidElement, - type ReactElement, - type ReactNode, - useContext, -} from 'react' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '../../lib/cn' - -const buttonGroupVariants = cva('inline-flex', { - variants: { - gap: { - none: 'gap-0', - sm: 'gap-0.5', - }, - }, - defaultVariants: { - gap: 'sm', - }, -}) - -interface ButtonGroupContextValue { - value: string | undefined - onValueChange: ((value: string) => void) | undefined - disabled: boolean -} - -const ButtonGroupContext = createContext(null) - -function useButtonGroupContext() { - const context = useContext(ButtonGroupContext) - if (!context) { - throw new Error('ButtonGroupItem must be used within a ButtonGroup') - } - return context -} - -interface ButtonGroupProps - extends Omit, 'onChange'>, - VariantProps { - /** Currently selected value */ - value?: string - /** Callback fired when selection changes */ - onValueChange?: (value: string) => void - /** Disables all items in the group */ - disabled?: boolean - children: ReactNode -} - -/** - * A group of connected toggle buttons where only one can be selected. - * - * @example - * ```tsx - * - * cURL - * Python - * JavaScript - * - * ``` - */ -function ButtonGroup({ - className, - gap, - value, - onValueChange, - disabled = false, - children, - ...props -}: ButtonGroupProps) { - const validChildren = Children.toArray(children).filter(isValidElement) - const childCount = validChildren.length - - return ( - -
- {validChildren.map((child, index) => { - const position: 'first' | 'middle' | 'last' | 'only' = - childCount === 1 - ? 'only' - : index === 0 - ? 'first' - : index === childCount - 1 - ? 'last' - : 'middle' - - return cloneElement(child as ReactElement, { - _position: position, - }) - })} -
-
- ) -} - -const buttonGroupItemVariants = cva( - 'inline-flex items-center justify-center transition-colors outline-hidden focus:outline-hidden focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-70 px-2 py-1 text-caption border', - { - variants: { - active: { - true: 'bg-[var(--text-primary)] text-[var(--text-inverse)] border-[var(--text-primary)] hover-hover:bg-[var(--text-primary)] hover-hover:text-[var(--text-inverse)] hover-hover:border-[var(--text-primary)] dark:bg-white dark:text-[var(--bg)] dark:border-white dark:hover-hover:bg-white dark:hover-hover:text-[var(--bg)] dark:hover-hover:border-white', - false: - 'bg-[var(--surface-4)] text-[var(--text-secondary)] border-[var(--border)] hover-hover:text-[var(--text-primary)] hover-hover:bg-[var(--surface-6)] hover-hover:border-[var(--border-1)]', - }, - position: { - only: 'rounded-[5px]', - first: 'rounded-l-[5px] rounded-r-none', - middle: 'rounded-none', - last: 'rounded-r-[5px] rounded-l-none', - }, - }, - defaultVariants: { - active: false, - position: 'only', - }, - } -) - -interface ButtonGroupItemProps extends Omit, 'onClick'> { - /** Value associated with this item */ - value: string - /** Disables this specific item */ - disabled?: boolean - /** @internal Position within the group, set automatically */ - _position?: 'first' | 'middle' | 'last' | 'only' -} - -/** - * An individual item within a ButtonGroup. - */ -function ButtonGroupItem({ - className, - value, - disabled: itemDisabled, - _position = 'only', - children, - ...props -}: ButtonGroupItemProps) { - const context = useButtonGroupContext() - const isActive = context.value === value - const isDisabled = context.disabled || itemDisabled - - const handleClick = () => { - if (!isDisabled && context.onValueChange) { - context.onValueChange(value) - } - } - - return ( - - ) -} - -ButtonGroup.displayName = 'ButtonGroup' -ButtonGroupItem.displayName = 'ButtonGroupItem' - -export { ButtonGroup, ButtonGroupItem, buttonGroupVariants, buttonGroupItemVariants } diff --git a/packages/emcn/src/components/chip-button-group/chip-button-group.test.tsx b/packages/emcn/src/components/chip-button-group/chip-button-group.test.tsx new file mode 100644 index 00000000000..6ffd61024ae --- /dev/null +++ b/packages/emcn/src/components/chip-button-group/chip-button-group.test.tsx @@ -0,0 +1,157 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ChipButtonGroup, ChipButtonGroupItem } from './chip-button-group' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(children: ReactNode): HTMLElement { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(children)) + const group = container.querySelector('[role="radiogroup"]') + if (!group) throw new Error('Button group not rendered') + return group +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null + vi.useRealTimers() +}) + +function ControlledGroup({ + onChange, + children, +}: { + onChange: (value: string) => void + children?: ReactNode +}) { + const [value, setValue] = useState('member') + return ( + { + setValue(next) + onChange(next) + }} + > + {children ?? ( + <> + Member accounts + + Service account Recommended + + + )} + + ) +} + +describe('ChipButtonGroup', () => { + it('preserves compound children, accessible labels, and selection callbacks', () => { + const onChange = vi.fn() + const group = mount() + const items = group.querySelectorAll('[role="radio"]') + expect(group.getAttribute('aria-label')).toBe('Connection type') + expect(items[1].querySelector('span')?.textContent).toBe('Recommended') + expect(items[0].getAttribute('aria-checked')).toBe('true') + act(() => items[1].click()) + expect(onChange).toHaveBeenCalledExactlyOnceWith('service') + expect(items[0].getAttribute('aria-checked')).toBe('false') + expect(items[1].getAttribute('aria-checked')).toBe('true') + act(() => items[1].click()) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('moves selection with arrow keys, skips disabled options, and wraps focus', () => { + vi.useFakeTimers() + const onChange = vi.fn() + const group = mount( + + Member accounts + + Service account + + API token + + ) + const items = group.querySelectorAll('[role="radio"]') + act(() => items[0].focus()) + for (const [key, expectedIndex, expectedValue] of [ + ['ArrowRight', 2, 'manual'], + ['ArrowRight', 0, 'member'], + ['ArrowLeft', 2, 'manual'], + ] as const) { + act(() => { + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + vi.runAllTimers() + }) + act(() => { + document.activeElement?.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })) + }) + expect(document.activeElement).toBe(items[expectedIndex]) + expect(items[expectedIndex].getAttribute('aria-checked')).toBe('true') + expect([...items].filter((item) => item.tabIndex === 0)).toEqual([items[expectedIndex]]) + expect(onChange).toHaveBeenLastCalledWith(expectedValue) + } + expect(onChange).toHaveBeenCalledTimes(3) + }) + + it('keeps selection controlled until the caller supplies a new value', () => { + const onChange = vi.fn() + const group = mount( + + Member accounts + Service account + + ) + const items = group.querySelectorAll('[role="radio"]') + act(() => items[1].click()) + expect(onChange).toHaveBeenCalledExactlyOnceWith('service') + expect(items[0].getAttribute('aria-checked')).toBe('true') + expect(items[1].getAttribute('aria-checked')).toBe('false') + }) + + it('disables the entire group without losing its selected value', () => { + const onChange = vi.fn() + const group = mount( + + Member accounts + Service account + + ) + const items = group.querySelectorAll('[role="radio"]') + expect([...items].every((item) => item.disabled)).toBe(true) + act(() => items[1].click()) + expect(onChange).not.toHaveBeenCalled() + expect(items[0].getAttribute('aria-checked')).toBe('true') + }) + + it('keeps a disabled option unavailable while other options remain selectable', () => { + const onChange = vi.fn() + const group = mount( + + Member accounts + + Service account + + API token + + ) + const items = group.querySelectorAll('[role="radio"]') + act(() => items[1].click()) + expect(onChange).not.toHaveBeenCalled() + act(() => items[2].click()) + expect(onChange).toHaveBeenCalledExactlyOnceWith('manual') + }) +}) diff --git a/packages/emcn/src/components/chip-button-group/chip-button-group.tsx b/packages/emcn/src/components/chip-button-group/chip-button-group.tsx new file mode 100644 index 00000000000..5b779537ff6 --- /dev/null +++ b/packages/emcn/src/components/chip-button-group/chip-button-group.tsx @@ -0,0 +1,138 @@ +'use client' + +import { createContext, type HTMLAttributes, type ReactNode, useContext, useRef } from 'react' +import * as RadioGroup from '@radix-ui/react-radio-group' +import type { VariantProps } from 'class-variance-authority' +import { useScrollEdges } from '../../hooks/use-scroll-edges' +import { cn } from '../../lib/cn' +import { segmentedControlItemVariants, segmentedControlVariants } from '../chip/segmented-control' +import { scrollFadeAttributes, scrollFadeXClass } from '../scroll-fade/scroll-fade' + +/** Shared chip chrome; preserves the compound control's existing gap default. */ +const chipButtonGroupVariants = ({ + gap = 'sm', + ...props +}: Parameters[0] = {}) => + segmentedControlVariants({ gap, ...props }) + +interface ChipButtonGroupContextValue { + value: string | undefined + size: 'default' | 'compact' +} + +const ChipButtonGroupContext = createContext(null) + +function useChipButtonGroupContext() { + const context = useContext(ChipButtonGroupContext) + if (!context) { + throw new Error('ChipButtonGroupItem must be used within a ChipButtonGroup') + } + return context +} + +export interface ChipButtonGroupProps + extends Omit, 'onChange'>, + VariantProps { + /** Currently selected value */ + value?: string + /** Callback fired when selection changes */ + onValueChange?: (value: string) => void + /** Disables all items in the group */ + disabled?: boolean + children: ReactNode +} + +/** + * A 30px segmented chip with compound children for rich option content. + * Its chrome is shared with `ChipSwitch`. Radix owns radio-group focus, + * arrow-key selection, and disabled-option behavior. + * + * @example + * ```tsx + * + * cURL + * Python + * JavaScript + * + * ``` + */ +function ChipButtonGroup({ + className, + gap, + size = 'default', + value, + onValueChange, + disabled = false, + children, + ...props +}: ChipButtonGroupProps) { + const groupRef = useRef(null) + const edges = useScrollEdges(groupRef, { axis: 'x' }) + return ( + + +
+ {children} +
+
+
+ ) +} + +const chipButtonGroupItemVariants = segmentedControlItemVariants + +export interface ChipButtonGroupItemProps + extends Omit, 'onClick'> { + /** Value associated with this item */ + value: string + /** Disables this specific item */ + disabled?: boolean +} + +/** + * An individual item within a ChipButtonGroup. + */ +function ChipButtonGroupItem({ + className, + value, + disabled: itemDisabled, + children, + ...props +}: ChipButtonGroupItemProps) { + const context = useChipButtonGroupContext() + const isActive = context.value === value + return ( + + + + ) +} + +ChipButtonGroup.displayName = 'ChipButtonGroup' +ChipButtonGroupItem.displayName = 'ChipButtonGroupItem' + +export { + ChipButtonGroup, + ChipButtonGroupItem, + chipButtonGroupVariants, + chipButtonGroupItemVariants, +} diff --git a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx index 5be4ab182ff..c6305e37e32 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx @@ -280,6 +280,64 @@ describe('ChipModalBody', () => { }) }) +describe('ChipModalField file actions', () => { + it('names each upload action with its field title', () => { + mount( + <> + {}} /> + {}} /> + + ) + const uploads = Array.from(document.querySelectorAll('input[type="file"]')).map( + (input) => input.closest('button')! + ) + expect(uploads.map((upload) => upload.labels?.[0]?.textContent)).toEqual([ + 'User mapping', + 'Project permissions', + ]) + for (const upload of uploads) expect(upload.labels?.[0]?.control).toBe(upload) + }) + + it('keeps the title action outside the label and upload control', () => { + const onDownload = vi.fn() + mount( + {}} + titleActions={} + /> + ) + const label = document.querySelector('label')! + const download = buttonByText('Download template') + const upload = document.querySelector('input[type="file"]')!.closest('button')! + expect(label.contains(download)).toBe(false) + expect(upload.contains(download)).toBe(false) + act(() => label.click()) + expect(onDownload).not.toHaveBeenCalled() + act(() => download.click()) + expect(onDownload).toHaveBeenCalledOnce() + }) + + it('resets the native input after every selection so the same file can be picked again', () => { + const onChange = vi.fn() + mount() + const input = document.querySelector('input[type="file"]')! + const file = new File(['1,alice@example.com'], 'users.csv', { type: 'text/csv' }) + Object.defineProperty(input, 'files', { value: [file], configurable: true }) + Object.defineProperty(input, 'value', { value: 'users.csv', writable: true }) + for (let selection = 0; selection < 2; selection++) { + input.value = 'users.csv' + act(() => input.dispatchEvent(new Event('change', { bubbles: true }))) + expect(input.value).toBe('') + } + expect(onChange).toHaveBeenCalledTimes(2) + expect(onChange).toHaveBeenLastCalledWith([file]) + expect(input.getAttribute('aria-label')).toBe('User mapping') + }) +}) + describe('ChipModal default actions', () => { beforeEach(makeElementsVisible) diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index aec4d78d119..b714d85f060 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -492,6 +492,8 @@ export type ChipModalDropdownOption = ChipDropdownOption interface ChipModalFieldBaseProps { /** Field title rendered above the control. Replaces the legacy `label` slot. */ title: React.ReactNode + /** Optional field actions beside the title, outside its label. */ + titleActions?: React.ReactNode /** * Renders a `*` marker after the title and sets `aria-required` on the * underlying control. @@ -743,13 +745,24 @@ function ChipModalField(props: ChipModalFieldProps) { const id = React.useId() const errorId = `${id}-error` const hintId = `${id}-hint` - const { title, required, error, hint, flush = false, className } = props + const { title, titleActions, required, error, hint, flush = false, className } = props const associatesLabel = props.type === 'input' || props.type === 'email' || props.type === 'textarea' || props.type === 'copy' || + props.type === 'file' || props.type === 'emails' + const label = ( + + ) return (
- + {titleActions ? ( +
+ {label} + {titleActions} +
+ ) : ( + label + )} {renderChipModalControl(props, id, errorId, hintId)} {error && props.type !== 'emails' ? (
- ) : error && filteredOptions.length === 0 && !hasMore ? ( + ) : error && filteredOptions.length === 0 ? (
{error}
@@ -994,9 +1013,11 @@ const Combobox = memo(
)} {group.items.map((option) => { - const isSelected = multiSelect - ? multiSelectValues?.includes(option.value) - : effectiveSelectedValue === option.value + const isSelected = + option.selected ?? + (multiSelect + ? multiSelectValues?.includes(option.value) + : effectiveSelectedValue === option.value) const globalIndex = filteredOptions.findIndex( (o) => o.value === option.value ) diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 039102ac8c2..20e11b11f4c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -2,7 +2,6 @@ export { Avatar, AvatarFallback, AvatarImage } from './avatar/avatar' export { Badge, type BadgeProps } from './badge/badge' export { Banner } from './banner/banner' export { Button, buttonVariants } from './button/button' -export { ButtonGroup, ButtonGroupItem } from './button-group/button-group' export { CalendarDayCell, type CalendarDayCellProps, @@ -37,6 +36,14 @@ export { chipRadiusClass, disclosureChevronClass, } from './chip/chip-chrome' +export { + ChipButtonGroup, + ChipButtonGroupItem, + type ChipButtonGroupItemProps, + type ChipButtonGroupProps, + chipButtonGroupItemVariants, + chipButtonGroupVariants, +} from './chip-button-group/chip-button-group' export { ChipCombobox } from './chip-combobox/chip-combobox' export { ChipCopyInput, @@ -68,6 +75,7 @@ export { ChipModalError, type ChipModalErrorProps, ChipModalField, + type ChipModalFieldAria, type ChipModalFieldProps, ChipModalFooter, type ChipModalFooterAction, diff --git a/packages/emcn/src/icons/animate/loader.module.css b/packages/emcn/src/icons/animate/loader.module.css index ebe4f368f54..5610cc63865 100644 --- a/packages/emcn/src/icons/animate/loader.module.css +++ b/packages/emcn/src/icons/animate/loader.module.css @@ -2,6 +2,9 @@ * Loader icon animation * Continuous spinning animation for loading states * Uses GPU acceleration for smooth performance + * The period is read from `--loader-duration` so a consumer can set it from a + * utility class (`[--loader-duration:650ms]`); the module's own `animation` + * shorthand is unlayered and would otherwise beat any Tailwind override. */ @keyframes spin { @@ -14,7 +17,7 @@ } .animated-loader-svg { - animation: spin 1s linear infinite; + animation: spin var(--loader-duration, 1s) linear infinite; transform-origin: center center; will-change: transform; } diff --git a/packages/emcn/src/icons/loader.tsx b/packages/emcn/src/icons/loader.tsx index 1b702c19e2a..c5d2367e30b 100644 --- a/packages/emcn/src/icons/loader.tsx +++ b/packages/emcn/src/icons/loader.tsx @@ -14,7 +14,9 @@ export interface LoaderProps extends SVGProps { * Loader icon component with optional CSS-based spinning animation * Based on refresh-cw but without the arrows, just the circular arcs. * When animate is false, this is a lightweight static icon with no animation overhead. - * When animate is true, CSS module animations are applied for continuous spin. + * When animate is true, CSS module animations are applied for continuous spin; + * the period defaults to 1s and follows `--loader-duration` when a consumer sets it + * (for example `className='[--loader-duration:650ms]'`). * @param props - SVG properties including className, animate, etc. */ export function Loader({ animate = false, className, ...props }: LoaderProps) { diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index a906c246abc..728153d9b0d 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -7,7 +7,7 @@ import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { filterUndefined, isRecordLike } from '@sim/utils/object' import chalk from 'chalk' -import { getRequestContext } from './request-context' +import { getRequestContext, type RequestContext } from './request-context' /** * LogLevel enum defines the severity levels for logging @@ -305,6 +305,29 @@ const materializeMetadata = (metadata: LoggerMetadata): LoggerMetadata => { return { metadataError: true } } +/** + * The request context's contribution to every log line in it. Only fields the + * request actually established are set, so a line outside a request, or before + * authentication, carries no empty keys to filter back out. + */ +const requestContextMetadata = (context: RequestContext): LoggerMetadata => { + const metadata: LoggerMetadata = { requestId: context.requestId } + if (context.method) metadata.method = context.method + if (context.path) metadata.path = context.path + if (context.traceId) metadata.traceId = context.traceId + if (context.client) { + metadata.surface = context.client.surface + if (context.client.version) metadata.clientVersion = context.client.version + if (context.client.agent) metadata.codingAgent = context.client.agent + } + if (context.auth) { + metadata.auth = context.auth.kind + if (context.auth.service) metadata.authService = context.auth.service + } + if (context.callChain) metadata.callDepth = context.callChain.length + return metadata +} + /** * Logger class for standardized console logging * @@ -402,13 +425,7 @@ export class Logger { const reqCtx = getRequestContext() const effectiveMetadata = reqCtx - ? { - requestId: reqCtx.requestId, - method: reqCtx.method, - path: reqCtx.path, - traceId: reqCtx.traceId, - ...this.metadata, - } + ? { ...requestContextMetadata(reqCtx), ...this.metadata } : this.metadata const metadataEntries = Object.entries(filterUndefined(effectiveMetadata)) const metadataStr = @@ -526,8 +543,13 @@ export function createLogger(module: string, config?: LoggerConfig): Logger { return new Logger(module, config) } -export type { RequestContext } from './request-context' -export { getRequestContext, runWithRequestContext, setRequestTraceId } from './request-context' +export type { RequestAuth, RequestContext } from './request-context' +export { + getRequestContext, + runWithRequestContext, + setRequestAuth, + setRequestTraceId, +} from './request-context' const OTEL_LOG_SEVERITY: Record = { [LogLevel.DEBUG]: { number: SeverityNumber.DEBUG, text: 'DEBUG' }, diff --git a/packages/logger/src/request-context.ts b/packages/logger/src/request-context.ts index c2e5923e032..ae07c2fd16b 100644 --- a/packages/logger/src/request-context.ts +++ b/packages/logger/src/request-context.ts @@ -1,3 +1,5 @@ +import type { ResolvedClientInfo } from '@sim/utils/client-info' + export interface RequestContext { requestId: string method?: string @@ -9,6 +11,37 @@ export interface RequestContext { * `setRequestTraceId` when the trace root is created locally. */ traceId?: string + /** + * Which official client sent the request (web, desktop, CLI, an SDK), when + * it could be established. Resolved once by the route handler so logs and + * analytics emitted anywhere in the request attribute it without each call + * site re-reading headers. + */ + client?: ResolvedClientInfo + /** + * How the request authenticated, stamped by the surface adapter once its + * credential resolved to a principal. Absent on public and unauthenticated + * requests, and until authentication has run. + */ + auth?: RequestAuth + /** + * The workflow-to-workflow call chain the request arrived with (`X-Sim-Via`), + * oldest first. Present only when one workflow's execution made this call. + */ + callChain?: readonly string[] +} + +/** + * The credential kind a request authenticated with, in the vocabulary of the + * principal it produced: `session`, `personal_api_key`, `workspace_api_key`, + * `oauth_access_token`, `delegated`, `system`, and so on. `service` names the + * delegating or system service (`copilot`, `schedule`, …) and `clientId` the + * OAuth client (`sim-cli`), when the kind carries one. + */ +export interface RequestAuth { + kind: string + service?: string + clientId?: string } /** @@ -64,3 +97,15 @@ export function setRequestTraceId(traceId: string): void { const store = storage.getStore() if (store && traceId) store.traceId = traceId } + +/** + * Records how the current request authenticated so every later log line and + * analytics event in it can say so. Authentication runs inside the handler, + * after the route context exists, hence a mutation of the live store rather + * than a field supplied at `runWithRequestContext` time. No-op outside a + * request context. + */ +export function setRequestAuth(auth: RequestAuth): void { + const store = storage.getStore() + if (store) store.auth = auth +} diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index cc577f82d87..1258b37c100 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -10,6 +10,7 @@ import time import random import os +import platform import requests @@ -21,6 +22,20 @@ _SUCCESSFUL_RUN_STATUSES = ('completed', 'paused') __version__ = "0.2.0" + + +def _client_headers() -> Dict[str, str]: + """ + Identify this SDK to the API on every request, the way every official Sim + client does, so a server log line or analytics event can say which client + made the call. ``X-Sim-Client-Info`` is the header the server reads. + """ + python_version = platform.python_version() + return { + 'User-Agent': f'simstudio-python-sdk/{__version__} python/{python_version}', + 'X-Sim-Client-Info': f'sdk-python/{__version__}; python/{python_version}', + } + __all__ = [ "SimStudioClient", "SimStudioError", @@ -133,6 +148,7 @@ def __init__(self, api_key: str, base_url: str = "https://sim.ai"): self.base_url = base_url.rstrip('/') self._session = requests.Session() self._session.headers.update({ + **_client_headers(), 'X-API-Key': self.api_key, 'Content-Type': 'application/json', }) diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 3ef5d711e88..eb084b2b45b 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -2,9 +2,11 @@ Tests for the Sim Python SDK """ +import platform + import pytest from unittest.mock import Mock, patch -from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus +from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus, __version__ def v2_execution_response(output=None, status="completed", error=None): @@ -765,3 +767,12 @@ def test_execute_workflow_with_dict_input_uses_v2_input_field(mock_post): request_body = call_args[1]["json"] assert request_body["input"] == {"ticker": "NVDA", "quantity": 100} + + +def test_identifies_the_sdk_on_every_request(): + client = SimStudioClient(api_key="test-key") + python_version = platform.python_version() + + assert client._session.headers["X-Sim-Client-Info"] == f"sdk-python/{__version__}; python/{python_version}" + assert client._session.headers["User-Agent"] == f"simstudio-python-sdk/{__version__} python/{python_version}" + assert client._session.headers["X-API-Key"] == "test-key" diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index d12e65321b9..366264388b4 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -25,6 +25,32 @@ You can also run a command without installing the package globally: npx sim --help ``` +## Updates + +The CLI checks for a newer stable release on eligible interactive invocations, +at most once per day. It prints an optional update notice and continues your +command. Updates install only when you run `sim update`. + +Update immediately, including in CI or with automatic checks disabled: + +```bash +sim update +``` + +The updater uses the package manager that installed the running copy and verifies +its global installation before making changes. Supported managers are npm, pnpm, +Bun, and Yarn Classic. Use `sim update --package-manager bun` if detection does +not match a custom installation. Manual updates preserve staging and dev channels. +Installation failures exit with an error; concurrent update attempts are refused. +The updater resolves the channel through that package manager, refuses older +releases, and installs the exact version it checked. + +Set `SIM_NO_UPDATE_CHECK=1` to disable update notices. Project-local installs and +temporary package-runner copies must be updated through their package manager. + +Older releases without `sim update` need one upgrade using the package manager +that installed them before this mechanism becomes available. + ## Get started Sign in to the default profile: @@ -290,12 +316,13 @@ The main environment variables are: | `SIM_CONFIG_DIR` | Base directory for CLI config, credentials, and the update cache | | `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely | | `SIM_DEBUG` | Print request diagnostics to stderr | -| `SIM_NO_UPDATE_CHECK` | Turn off the update notice | +| `SIM_NO_UPDATE_CHECK` | Turn off update notices | +| `SIM_TELEMETRY_DISABLED` | Turn off anonymous usage reporting (`DO_NOT_TRACK=1` also works) | On eligible interactive invocations, `sim` uses a daily cache before asking -`registry.npmjs.org` what is published under the `latest` tag and prints one -line on stderr when a newer version exists. Prerelease installs are skipped -entirely. The cache lives in `~/.sim` by default and follows `SIM_CONFIG_DIR`; +`registry.npmjs.org` what is published under the `latest` tag and prints an +optional notice on stderr when a newer version exists. Prerelease installs are +skipped entirely. The cache lives in `~/.sim` by default and follows `SIM_CONFIG_DIR`; without a writable cache, each eligible invocation checks again. Concurrent invocations can also perform duplicate checks. The registry request has a one-second deadline; the short-lived request process is terminated on expiry. @@ -308,6 +335,22 @@ use the public default; non-empty malformed or non-HTTP(S) values fail closed. The full list of cases where it stays quiet is in the [configuration guide](https://docs.sim.ai/cli/configuration). +## Usage data + +The CLI reports anonymous usage data — which commands run, whether they +succeed, and how long they take — so the team can see how it is used. Nothing +you type is sent: no argument or flag values, paths, ids, error messages, or +credentials. The first interactive run prints a notice and is not reported. + +```bash +sim telemetry status +sim telemetry disable +``` + +`DO_NOT_TRACK=1` or `SIM_TELEMETRY_DISABLED=1` in the environment also turns it +off. The full description of what is sent is in the +[usage data guide](https://docs.sim.ai/cli/usage-data). + ## Documentation - [CLI documentation](https://docs.sim.ai/cli) @@ -316,6 +359,7 @@ The full list of cases where it stays quiet is in the - [Profiles and configuration](https://docs.sim.ai/cli/configuration) - [Scripting](https://docs.sim.ai/cli/scripting) - [Troubleshooting](https://docs.sim.ai/cli/troubleshooting) +- [Usage data](https://docs.sim.ai/cli/usage-data) ## License diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 5bd8b1ce27a..c6ec88be5f1 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -3,12 +3,15 @@ "version": "2.1.2", "description": "Sim CLI - talk to the Sim API from your terminal", "type": "module", + "imports": { + "#sim-cli/*": "./src/*.ts" + }, "bin": { "sim": "dist/index.js" }, "scripts": { "prebuild": "bun run clean", - "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --outfile=dist/index.js", + "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --env='SIM_CLI_TELEMETRY_*' --outfile=dist/index.js", "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", diff --git a/packages/sim-cli/src/commands/telemetry.test.ts b/packages/sim-cli/src/commands/telemetry.test.ts new file mode 100644 index 00000000000..6df6124fed7 --- /dev/null +++ b/packages/sim-cli/src/commands/telemetry.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readTelemetryState } from '../telemetry/index' +import { telemetryCommand } from './telemetry' + +let dir: string +let output: string[] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-telemetry-')) + vi.stubEnv('SIM_CONFIG_DIR', dir) + output = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + output.push(line) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +function run(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(telemetryCommand()) + return root.parseAsync(['node', 'sim', 'telemetry', ...args]) +} + +describe('sim telemetry', () => { + it('saves the disable setting and reports the state', async () => { + await run('disable') + + expect(readTelemetryState(join(dir, 'telemetry.json'))?.enabled).toBe(false) + expect(output[0]).toMatch(/off/) + expect(output[0]).toContain('sim telemetry enable') + }) + + it('reports the environment override rather than the saved setting', async () => { + vi.stubEnv('DO_NOT_TRACK', '1') + + await run('enable') + + expect(readTelemetryState(join(dir, 'telemetry.json'))?.enabled).toBe(true) + expect(output[0]).toContain('DO_NOT_TRACK') + }) + + it('names a build without a destination', async () => { + await run('status') + + expect(output[0]).toMatch(/off: this build has no reporting destination/) + expect(output[1]).toContain('https://docs.sim.ai/cli/usage-data') + }) +}) diff --git a/packages/sim-cli/src/commands/telemetry.ts b/packages/sim-cli/src/commands/telemetry.ts new file mode 100644 index 00000000000..9bb8368c0a5 --- /dev/null +++ b/packages/sim-cli/src/commands/telemetry.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander' +import { + builtInIngestTarget, + DO_NOT_TRACK_VARIABLE, + loadTelemetryState, + TELEMETRY_DISABLED_VARIABLE, + type TelemetryStatus, + telemetryStatus, + writeTelemetryState, +} from '../telemetry/index' +import { USAGE_DATA_DOCS_URL } from '../telemetry/invocation' + +/** One line per state, naming the thing the user can change when it is off. */ +function describe(status: TelemetryStatus): string { + if (status.enabled) return 'Usage reporting is on.' + switch (status.reason) { + case 'do_not_track': + return `Usage reporting is off: ${DO_NOT_TRACK_VARIABLE} is set.` + case 'environment': + return `Usage reporting is off: ${TELEMETRY_DISABLED_VARIABLE} is set.` + case 'setting': + return 'Usage reporting is off. Turn it on with: sim telemetry enable' + case 'unconfigured': + return 'Usage reporting is off: this build has no reporting destination.' + } +} + +function currentStatus(): TelemetryStatus { + return telemetryStatus({ + env: process.env, + state: loadTelemetryState(), + configured: builtInIngestTarget() !== undefined, + }) +} + +/** + * Saves the setting, then reports the resulting state rather than the saved + * value: `enable` under `DO_NOT_TRACK=1` must not print "on" when nothing + * will be sent. + */ +function setEnabled(enabled: boolean): void { + writeTelemetryState({ ...loadTelemetryState(), enabled }) + console.log(describe(currentStatus())) +} + +export function telemetryCommand(): Command { + const telemetry = new Command('telemetry').description('Control anonymous usage reporting') + + telemetry + .command('status') + .description('Show whether usage reporting is on, and why not if it is off') + .action(() => { + console.log(describe(currentStatus())) + console.log(`Learn more: ${USAGE_DATA_DOCS_URL}`) + }) + + telemetry + .command('enable') + .description('Turn usage reporting on for this machine') + .action(() => setEnabled(true)) + + telemetry + .command('disable') + .description('Turn usage reporting off for this machine') + .action(() => setEnabled(false)) + + return telemetry +} diff --git a/packages/sim-cli/src/commands/update.test.ts b/packages/sim-cli/src/commands/update.test.ts new file mode 100644 index 00000000000..155b3ae9dff --- /dev/null +++ b/packages/sim-cli/src/commands/update.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { installUpdate, announceUpdateIfAvailable } = vi.hoisted(() => ({ + installUpdate: vi.fn(), + announceUpdateIfAvailable: vi.fn(), +})) + +vi.mock('#sim-cli/update/install', () => ({ installUpdate })) +vi.mock('#sim-cli/update/check', () => ({ announceUpdateIfAvailable })) + +import { buildProgram } from '#sim-cli/program' + +beforeEach(() => { + vi.clearAllMocks() + installUpdate.mockResolvedValue(undefined) + announceUpdateIfAvailable.mockResolvedValue(undefined) +}) + +describe('update command wiring', () => { + it('runs a manual update without the daily check or authentication', async () => { + await buildProgram().parseAsync(['node', 'sim', 'update']) + expect(announceUpdateIfAvailable).not.toHaveBeenCalled() + expect(installUpdate).toHaveBeenCalledExactlyOnceWith({ packageManager: undefined }) + }) + + it('passes an explicit package manager to the updater', async () => { + await buildProgram().parseAsync(['node', 'sim', 'update', '--package-manager', 'bun']) + expect(installUpdate).toHaveBeenCalledExactlyOnceWith({ packageManager: 'bun' }) + }) + + it('checks for a notice and continues the requested action without installing', async () => { + const program = buildProgram() + const action = vi.fn() + program.commands.find((command) => command.name() === 'whoami')!.action(action) + await program.parseAsync(['node', 'sim', 'whoami']) + expect(announceUpdateIfAvailable).toHaveBeenCalledOnce() + expect(action).toHaveBeenCalledOnce() + expect(installUpdate).not.toHaveBeenCalled() + }) + + it('propagates an explicit update failure', async () => { + installUpdate.mockRejectedValueOnce(new Error('installation failed')) + const program = buildProgram() + await expect(program.parseAsync(['node', 'sim', 'update'])).rejects.toThrow( + 'installation failed' + ) + expect(announceUpdateIfAvailable).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/update.ts b/packages/sim-cli/src/commands/update.ts new file mode 100644 index 00000000000..7dcd6f5e594 --- /dev/null +++ b/packages/sim-cli/src/commands/update.ts @@ -0,0 +1,15 @@ +import { Command, Option } from 'commander' +import { installUpdate, type PackageManager } from '#sim-cli/update/install' + +export function updateCommand(): Command { + return new Command('update') + .description('Update this global CLI installation to the newest release on its channel') + .addOption( + new Option('--package-manager ', 'Package manager that installed this copy').choices( + ['npm', 'pnpm', 'bun', 'yarn'] + ) + ) + .action(async (options: { packageManager?: PackageManager }) => { + await installUpdate({ packageManager: options.packageManager }) + }) +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index e7256a7ae77..7679ce4ba79 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -1,4 +1,4 @@ -export { configDir, configPath, credentialsPath } from './paths' +export { configDir, configPath, credentialsPath, telemetryStatePath } from './paths' export { DEFAULT_ENDPOINT, DEFAULT_PROFILE, diff --git a/packages/sim-cli/src/config/json-file.ts b/packages/sim-cli/src/config/json-file.ts new file mode 100644 index 00000000000..61a03d32985 --- /dev/null +++ b/packages/sim-cli/src/config/json-file.ts @@ -0,0 +1,103 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname } from 'node:path' + +/** + * Small JSON state files under the config directory: the update-check cache + * and the telemetry state. Both are best-effort — a file that cannot be read + * or written must never fail the command that touched it — and both sit in a + * directory an attacker who controls the account could pre-populate, so reads + * are bounded and refuse symlinks, and writes replace atomically. + */ + +/** Makes adjacent temporary files unique across writes in this process. */ +let writeSequence = 0 + +/** + * Reads and parses a JSON file, or returns `null` for anything at all wrong. + * + * Follows no symlink and reads no more than `maxBytes`: the file lives where + * the user, or anything running as the user, can replace it, and the caller's + * only interest is in a small document it wrote itself. Shape validation is + * the caller's — this returns whatever JSON was there. + */ +export function readJsonFile(path: string, maxBytes: number): unknown { + let descriptor: number | null = null + try { + if (!lstatSync(path).isFile()) return null + descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) + const stats = fstatSync(descriptor) + if (!stats.isFile() || stats.size > maxBytes) return null + + const buffer = Buffer.allocUnsafe(maxBytes + 1) + let bytesRead = 0 + while (bytesRead < buffer.byteLength) { + const count = readSync( + descriptor, + buffer, + bytesRead, + buffer.byteLength - bytesRead, + bytesRead + ) + if (count === 0) break + bytesRead += count + } + if (bytesRead > maxBytes) return null + + return JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')) + } catch { + return null + } finally { + if (descriptor !== null) { + try { + closeSync(descriptor) + } catch {} + } + } +} + +/** + * Replaces a JSON file atomically, creating its directory if needed. + * + * An exclusive adjacent temporary file renamed into place means a reader never + * sees a partial document and a linked target is never modified through the + * link. Failures are swallowed: the callers are caches and preferences whose + * loss costs one extra request or one repeated notice. + */ +export function writeJsonFile(path: string, value: unknown, mode = 0o644): void { + let descriptor: number | null = null + let temporaryCreated = false + const temporaryPath = `${path}.${process.pid}.${Date.now()}.${writeSequence++}.tmp` + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + descriptor = openSync(temporaryPath, 'wx', mode) + temporaryCreated = true + writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`) + closeSync(descriptor) + descriptor = null + renameSync(temporaryPath, path) + temporaryCreated = false + } catch { + } finally { + if (descriptor !== null) { + try { + closeSync(descriptor) + } catch {} + } + if (temporaryCreated) { + try { + unlinkSync(temporaryPath) + } catch {} + } + } +} diff --git a/packages/sim-cli/src/config/paths.ts b/packages/sim-cli/src/config/paths.ts index 9618931e080..d7620fd4eb6 100644 --- a/packages/sim-cli/src/config/paths.ts +++ b/packages/sim-cli/src/config/paths.ts @@ -33,3 +33,14 @@ export function credentialsPath(): string { export function updateCachePath(): string { return join(configDir(), 'update-check.json') } + +/** + * Where usage telemetry keeps its device id, session, and on/off setting. + * + * State rather than configuration, so it follows `SIM_CONFIG_DIR` the way the + * update cache does and gets no override of its own. Deleting it forgets the + * device id and shows the first-run notice again; nothing else is lost. + */ +export function telemetryStatePath(): string { + return join(configDir(), 'telemetry.json') +} diff --git a/packages/sim-cli/src/environment.ts b/packages/sim-cli/src/environment.ts new file mode 100644 index 00000000000..0fcee5907ef --- /dev/null +++ b/packages/sim-cli/src/environment.ts @@ -0,0 +1,55 @@ +/** + * Facts about the process environment that more than one feature reads. + * + * The update notice and usage telemetry both suppress themselves in CI and + * both read `SIM_*` switches; one definition keeps the two from disagreeing + * about what "on" or "in CI" means. + */ + +/** Covers CI jobs that allocate a terminal despite being non-interactive. */ +const CI_VARIABLES = [ + 'CI', + 'GITHUB_ACTIONS', + 'JENKINS_URL', + 'TEAMCITY_VERSION', + 'BUILDKITE', +] as const + +/** Anything but unset, empty, `0` or `false` turns a switch on. */ +export function isEnabled(value: string | undefined): boolean { + if (value === undefined) return false + const normalized = value.trim().toLowerCase() + return normalized !== '' && normalized !== '0' && normalized !== 'false' +} + +/** Whether any of the {@link CI_VARIABLES} says this is a CI job. */ +export function isCi(env: NodeJS.ProcessEnv = process.env): boolean { + return CI_VARIABLES.some((variable) => isEnabled(env[variable])) +} + +/** + * The Node proxy flags this process was started with, for a child that must + * reach the network the same way. + */ +export function proxyExecArgv(): string[] { + return process.execArgv.filter( + (argument) => argument === '--use-env-proxy' || argument === '--no-use-env-proxy' + ) +} + +/** + * The process environment for a helper child: proxy and TLS settings intact, + * the named variables removed so a credential never reaches a process that + * does not need it, and `extra` added on top. + */ +export function childProcessEnv( + strip: readonly string[], + extra: NodeJS.ProcessEnv = {} +): NodeJS.ProcessEnv { + const env = { ...process.env } + const stripped = new Set(strip.map((name) => name.toLowerCase())) + for (const key of Object.keys(env)) { + if (stripped.has(key.toLowerCase())) delete env[key] + } + return { ...env, ...extra } +} diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index db155fcc0e5..ff7e08a669f 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,5 +1,7 @@ +import { CLIENT_INFO_HEADER } from '@sim/utils/client-info' import chalk from 'chalk' import type { ResolvedProfile, StoredCredential, StoredOAuthCredential } from '../config/index' +import { clientInfoHeader } from '../telemetry/client-info' import { USER_AGENT } from '../version' import { warnIfCredentialOverCleartext, warnIfProxyIgnored } from './environment' @@ -619,6 +621,7 @@ export class SimClient { : {}), accept: 'application/json', 'user-agent': USER_AGENT, + [CLIENT_INFO_HEADER]: clientInfoHeader(), ...(hasBody ? { 'content-type': 'application/json' } : {}), ...options.headers, }, diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 36a983333f8..ecb31d618b0 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node import chalk from 'chalk' +import type { Command } from 'commander' import { dump } from 'js-yaml' +import { CliUpdateError } from '#sim-cli/update/install' import { ProfileConfigError } from './config/index' import { clientFrom } from './context' import { @@ -12,57 +14,73 @@ import { } from './http/client' import { sanitize } from './output/render' import { buildProgram } from './program' +import { createCommandTelemetry } from './telemetry/index' /** - * Anything the CLI can explain prints as one line and exits 1. An unexpected - * error keeps its stack trace — that is a bug in the CLI, and hiding it behind a - * friendly message would make it unreportable. + * Prints the one-line explanation for an error the CLI understands and returns + * the exit code it deserves, or `null` for an error it does not: that is a bug + * in the CLI, and hiding it behind a friendly message would make it + * unreportable, so the caller lets it keep its stack trace. + */ +function explainFailure(error: unknown, program: Command): number | null { + if (error instanceof ProfileConfigError || error instanceof CliUpdateError) { + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + return 1 + } + // `AbortSignal.timeout` keeps firing after `fetch` resolves, so a bound that + // elapses while the body is still being read — a large `files get`, say — + // surfaces here rather than inside the client. A user's own Ctrl-C raises + // `AbortError` instead, which is deliberately left alone. + if (isRequestTimeout(error)) { + console.error(chalk.red(`Error: the request timed out. ${RAISE_TIMEOUT_HINT}`)) + return 1 + } + if (error instanceof SimApiError) { + let output = program.opts().output + try { + output = clientFrom(program).profile.output + } catch { + /** Preserve the original error when configuration is invalid. */ + } + if (output === 'json' || output === 'yaml') { + const payload = { + error: { + code: error.code ?? 'CLI_ERROR', + message: error.message, + ...(error.details === undefined ? {} : { details: error.details }), + }, + } + process.stderr.write(output === 'json' ? `${JSON.stringify(payload)}\n` : dump(payload)) + return error.exitCode + } + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) + if (error.details !== undefined) { + for (const line of formatApiErrorDetails(error.details)) { + console.error(chalk.dim(sanitize(line))) + } + } + return error.exitCode + } + return null +} + +/** + * Anything the CLI can explain prints as one line and exits with its code. A + * failure is reported here, where its class and code are known; every other + * way the process ends is reported from the exit listener telemetry installs. */ async function main() { + const telemetry = createCommandTelemetry() const program = buildProgram() + telemetry.observe(program) try { await program.parseAsync(process.argv) } catch (error) { - if (error instanceof ProfileConfigError) { - console.error(chalk.red(`Error: ${sanitize(error.message)}`)) - process.exit(1) - } - // `AbortSignal.timeout` keeps firing after `fetch` resolves, so a bound that - // elapses while the body is still being read — a large `files get`, say — - // surfaces here rather than inside the client. A user's own Ctrl-C raises - // `AbortError` instead, which is deliberately left alone. - if (isRequestTimeout(error)) { - console.error(chalk.red(`Error: the request timed out. ${RAISE_TIMEOUT_HINT}`)) - process.exit(1) - } - if (error instanceof SimApiError) { - let output = program.opts().output - try { - output = clientFrom(program).profile.output - } catch { - /** Preserve the original error when configuration is invalid. */ - } - if (output === 'json' || output === 'yaml') { - const payload = { - error: { - code: error.code ?? 'CLI_ERROR', - message: error.message, - ...(error.details === undefined ? {} : { details: error.details }), - }, - } - process.stderr.write(output === 'json' ? `${JSON.stringify(payload)}\n` : dump(payload)) - process.exit(error.exitCode) - } - console.error(chalk.red(`Error: ${sanitize(error.message)}`)) - if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) - if (error.details !== undefined) { - for (const line of formatApiErrorDetails(error.details)) { - console.error(chalk.dim(sanitize(line))) - } - } - process.exit(error.exitCode) - } - throw error + const exitCode = explainFailure(error, program) + telemetry.complete({ exitCode: exitCode ?? 1, error }) + if (exitCode === null) throw error + process.exit(exitCode) } } diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index 28eeb00a8be..42125e2f4c6 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -1,9 +1,11 @@ import { Command, Option } from 'commander' +import { updateCommand } from '#sim-cli/commands/update' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth' import { configureCommand } from './commands/configure' import { attachCredentialCommands } from './commands/credentials' import { attachProtocolCommands } from './commands/protocol/index' import { attachSecretCommands } from './commands/secrets' +import { telemetryCommand } from './commands/telemetry' import { OUTPUT_FORMATS } from './config/index' import { assertNoReservedProgramFlags, @@ -142,6 +144,9 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addCommand(whoamiCommand()) program.addCommand(profilesCommand()) program.addCommand(configureCommand()) + const update = updateCommand() + program.addCommand(update) + program.addCommand(telemetryCommand()) for (const command of buildGeneratedCommands()) { program.addCommand(command) @@ -153,7 +158,10 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addHelpText('after', HELP_EPILOGUE) - program.hook('preAction', () => announceUpdateIfAvailable()) + program.hook('preAction', async (_program, command) => { + if (command === update) return + await announceUpdateIfAvailable() + }) refuseHelpAfterUnknownCommand(program) assertNoReservedProgramFlags(program) diff --git a/packages/sim-cli/src/telemetry/client-info.test.ts b/packages/sim-cli/src/telemetry/client-info.test.ts new file mode 100644 index 00000000000..c61ef331c8b --- /dev/null +++ b/packages/sim-cli/src/telemetry/client-info.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLI_VERSION } from '../version' +import { clientInfoHeader } from './client-info' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-client-info-')) + vi.stubEnv('SIM_CONFIG_DIR', dir) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('clientInfoHeader', () => { + it('names the CLI, its runtime, the platform, and the driving agent', () => { + expect(clientInfoHeader({ CLAUDECODE: '1' })).toBe( + `cli/${CLI_VERSION}; node/${process.versions.node}; os/${process.platform}; arch/${process.arch}; agent/claude-code` + ) + }) + + it('withholds the agent when usage reporting is opted out', () => { + const header = clientInfoHeader({ CLAUDECODE: '1', DO_NOT_TRACK: '1' }) + expect(header).not.toContain('agent/') + expect(header).toContain(`cli/${CLI_VERSION}`) + }) +}) diff --git a/packages/sim-cli/src/telemetry/client-info.ts b/packages/sim-cli/src/telemetry/client-info.ts new file mode 100644 index 00000000000..b246ce2a5f5 --- /dev/null +++ b/packages/sim-cli/src/telemetry/client-info.ts @@ -0,0 +1,41 @@ +import { formatClientInfo } from '@sim/utils/client-info' +import { CLI_VERSION } from '../version' +import { detectCodingAgent } from './coding-agent' +import { telemetryStatus } from './policy' +import { loadTelemetryState } from './state' + +/** The process's own value; an explicit environment (tests) is never cached. */ +let cached: string | undefined + +/** + * The `X-Sim-Client-Info` value: the same facts as the user agent, in the + * header every official client sends, plus the AI coding agent driving this + * shell when one can be detected. The server reads this header, not the user + * agent, so a request from the CLI is attributed to the CLI on every log line + * and analytics event it produces. + * + * The agent is usage data, so it goes only where usage reporting is allowed: + * `DO_NOT_TRACK`, `SIM_TELEMETRY_DISABLED`, and `sim telemetry disable` all + * withhold it. Whether this build has a reporting destination is irrelevant — + * the server, not the CLI, is what records it. Computed once per process. + */ +export function clientInfoHeader(env: NodeJS.ProcessEnv = process.env): string { + if (env !== process.env) return buildClientInfoHeader(env) + cached ??= buildClientInfoHeader(env) + return cached +} + +function buildClientInfoHeader(env: NodeJS.ProcessEnv): string { + return formatClientInfo({ + surface: 'cli', + version: CLI_VERSION, + runtime: { name: 'node', version: process.versions.node }, + os: process.platform, + arch: process.arch, + ...(reportingAllowed(env) ? { agent: detectCodingAgent(env) } : {}), + }) +} + +function reportingAllowed(env: NodeJS.ProcessEnv): boolean { + return telemetryStatus({ env, state: loadTelemetryState(), configured: true }).enabled +} diff --git a/packages/sim-cli/src/telemetry/coding-agent.test.ts b/packages/sim-cli/src/telemetry/coding-agent.test.ts new file mode 100644 index 00000000000..583afe1e9d3 --- /dev/null +++ b/packages/sim-cli/src/telemetry/coding-agent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { detectCodingAgent } from './coding-agent' + +describe('detectCodingAgent', () => { + it('reports nothing for a person at a terminal', () => { + expect(detectCodingAgent({ TERM_PROGRAM: 'iTerm.app', SHELL: '/bin/zsh' })).toBeUndefined() + }) + + it.each([ + [{ CLAUDECODE: '1' }, 'claude-code'], + [{ CLAUDE_CODE: '1' }, 'claude-code'], + [{ CLAUDECODE: '1', CLAUDE_CODE_IS_COWORK: '1' }, 'cowork'], + [{ CODEX_THREAD_ID: 'thr_1' }, 'codex'], + [{ CODEX_SANDBOX: 'seatbelt' }, 'codex'], + [{ GEMINI_CLI: '1' }, 'gemini-cli'], + [{ CURSOR_AGENT: '1' }, 'cursor'], + [{ CURSOR_TRACE_ID: 'abc' }, 'cursor'], + [{ CURSOR_EXTENSION_HOST_ROLE: 'agent-exec' }, 'cursor'], + [{ OPENCODE: '1', AGENT: '1' }, 'opencode'], + [{ CLINE_ACTIVE: 'true' }, 'cline'], + [{ OZ_RUN_ID: 'run_1' }, 'warp'], + [{ PI_CODING_AGENT: 'true' }, 'pi'], + ])('recognises %o as %s', (env, expected) => { + expect(detectCodingAgent(env)).toBe(expected) + }) + + it('names Amp rather than the Claude Code marker it also sets', () => { + expect(detectCodingAgent({ AGENT: 'amp', CLAUDECODE: '1' })).toBe('amp') + expect(detectCodingAgent({ AMP_CURRENT_THREAD_ID: 'T-1', CLAUDECODE: '1' })).toBe('amp') + }) + + it('lets an agent declare its own name over every vendor marker', () => { + expect(detectCodingAgent({ AI_AGENT: 'Some-Agent_2', CLAUDECODE: '1' })).toBe('some-agent_2') + }) + + it('ignores a declared name that is not a well-formed token', () => { + expect(detectCodingAgent({ AI_AGENT: 'not a token', CLAUDECODE: '1' })).toBe('claude-code') + expect(detectCodingAgent({ AI_AGENT: 'x'.repeat(65) })).toBeUndefined() + }) + + it('ignores markers that only mean an agent is installed', () => { + expect(detectCodingAgent({ REPL_ID: 'abc', GOOSE_PROVIDER: 'x', AIDER_API_KEY: 'k' })).toBe( + undefined + ) + }) + + it('ignores a cursor role that is not the agent executor', () => { + expect(detectCodingAgent({ CURSOR_EXTENSION_HOST_ROLE: 'ui' })).toBeUndefined() + }) +}) diff --git a/packages/sim-cli/src/telemetry/coding-agent.ts b/packages/sim-cli/src/telemetry/coding-agent.ts new file mode 100644 index 00000000000..e0fd2c58b82 --- /dev/null +++ b/packages/sim-cli/src/telemetry/coding-agent.ts @@ -0,0 +1,85 @@ +/** + * Detects the AI coding agent whose shell this process runs in. + * + * Agents mark the shells they spawn with an environment variable, and the CLI + * reports that mark so usage driven by an agent can be told apart from a person + * at a terminal. The checks, their order, and the names follow the GitHub CLI + * (`internal/agents/detect.go`), which is the most complete verified table: + * generic conventions first, then vendor markers, with the more specific + * marker ahead of a broader one it implies (Amp sets `CLAUDECODE` too; Cowork + * is Claude Code plus its own flag). + * + * Only markers an agent sets on the shells it drives are consulted. Variables + * that merely mean an agent is installed or configured — `REPL_ID`, + * `GOOSE_PROVIDER`, `AIDER_*`, `COPILOT_*` — are deliberately absent, because + * they would attribute a person's own command to an agent. + */ + +/** The value an agent may declare itself with under the generic conventions. */ +const AGENT_NAME_PATTERN = /^[a-z0-9_-]+$/i +const MAX_AGENT_NAME_LENGTH = 64 + +interface AgentMarker { + readonly name: string + readonly matches: (env: NodeJS.ProcessEnv) => boolean +} + +const anyOf = + (...variables: readonly string[]) => + (env: NodeJS.ProcessEnv) => + variables.some((variable) => Boolean(env[variable])) + +/** Vendor markers, most specific first. */ +const AGENT_MARKERS: readonly AgentMarker[] = [ + { name: 'amp', matches: (env) => env.AGENT === 'amp' || Boolean(env.AMP_CURRENT_THREAD_ID) }, + { + name: 'codex', + matches: anyOf( + 'CODEX_THREAD_ID', + 'CODEX_SANDBOX', + 'CODEX_CI', + 'CODEX_SANDBOX_NETWORK_DISABLED' + ), + }, + { name: 'gemini-cli', matches: anyOf('GEMINI_CLI') }, + { name: 'opencode', matches: anyOf('OPENCODE') }, + { name: 'antigravity', matches: anyOf('ANTIGRAVITY_AGENT') }, + { name: 'augment', matches: anyOf('AUGMENT_AGENT') }, + { name: 'cline', matches: anyOf('CLINE_ACTIVE') }, + { name: 'cowork', matches: anyOf('CLAUDE_CODE_IS_COWORK') }, + { name: 'claude-code', matches: anyOf('CLAUDECODE', 'CLAUDE_CODE') }, + { + name: 'cursor', + matches: (env) => + anyOf('CURSOR_AGENT', 'CURSOR_TRACE_ID')(env) || + env.CURSOR_EXTENSION_HOST_ROLE === 'agent-exec', + }, + { name: 'warp', matches: anyOf('OZ_RUN_ID') }, + { name: 'pi', matches: anyOf('PI_CODING_AGENT') }, + { name: 'crush', matches: anyOf('CRUSH') }, +] + +/** A name an agent declared for itself, when it is a well-formed token. */ +function declaredAgentName(value: string | undefined): string | undefined { + const trimmed = value?.trim().toLowerCase() + if (!trimmed || trimmed.length > MAX_AGENT_NAME_LENGTH) return undefined + return AGENT_NAME_PATTERN.test(trimmed) ? trimmed : undefined +} + +/** + * The agent driving this shell, or `undefined` for a person at a terminal. + * + * `AI_AGENT` and `AGENT` are the two generic conventions agents have converged + * on for naming themselves and win over vendor markers when set. `AGENT` is + * consulted only when it carries a name: OpenCode sets it to `1`, which names + * nothing, and its own marker handles it. + */ +export function detectCodingAgent(env: NodeJS.ProcessEnv = process.env): string | undefined { + const declared = declaredAgentName(env.AI_AGENT) + if (declared) return declared + + const generic = declaredAgentName(env.AGENT) + if (generic && generic !== '1') return generic + + return AGENT_MARKERS.find((marker) => marker.matches(env))?.name +} diff --git a/packages/sim-cli/src/telemetry/index.ts b/packages/sim-cli/src/telemetry/index.ts new file mode 100644 index 00000000000..8fffcc31156 --- /dev/null +++ b/packages/sim-cli/src/telemetry/index.ts @@ -0,0 +1,10 @@ +export { clientInfoHeader } from './client-info' +export { createCommandTelemetry } from './invocation' +export { + DO_NOT_TRACK_VARIABLE, + TELEMETRY_DISABLED_VARIABLE, + type TelemetryStatus, + telemetryStatus, +} from './policy' +export { loadTelemetryState, readTelemetryState, writeTelemetryState } from './state' +export { builtInIngestTarget } from './transport' diff --git a/packages/sim-cli/src/telemetry/invocation.test.ts b/packages/sim-cli/src/telemetry/invocation.test.ts new file mode 100644 index 00000000000..81a803f31ba --- /dev/null +++ b/packages/sim-cli/src/telemetry/invocation.test.ts @@ -0,0 +1,319 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../http/client' +import { CLI_VERSION } from '../version' +import { + COMMAND_EVENT, + type CommandEventProperties, + type CommandTelemetryOptions, + createCommandTelemetry, + FIRST_RUN_NOTICE, +} from './invocation' +import { loadTelemetryState, readTelemetryState, writeTelemetryState } from './state' +import type { CaptureRequest, IngestTarget } from './transport' + +let dir: string +let statePath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-telemetry-')) + statePath = join(dir, 'telemetry.json') + vi.stubEnv('SIM_CONFIG_DIR', dir) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +const TARGET = { key: 'phc_test', host: 'https://us.i.posthog.com' } +const NOW = new Date('2026-09-10T12:00:00.000Z') + +/** A program shaped like the shipped one: root globals, a group with a leaf, and the telemetry group. */ +function buildProgram(): Command { + const program = new Command('sim') + .exitOverride() + .option('-P, --profile ') + .option('--endpoint ') + .option('-w, --workspace ') + .option('--output ') + const workflows = new Command('workflows') + workflows + .command('list') + .option('--limit ') + .option('--all') + .action(() => {}) + workflows + .command('get') + .argument('') + .action(() => {}) + workflows.command('fail').action(() => { + throw new SimApiError('Not found', 404, 'NOT_FOUND') + }) + program.addCommand(workflows) + const telemetry = new Command('telemetry') + telemetry.command('disable').action(() => {}) + program.addCommand(telemetry) + return program +} + +type SentRequest = CaptureRequest + +/** The one request a harness sent, typed as the CLI builds it. */ +function sentBy(send: { mock: { calls: unknown[][] } }): SentRequest { + return send.mock.calls[0][1] as SentRequest +} + +function harness(overrides: Partial = {}) { + const send = vi.fn<(target: IngestTarget, request: CaptureRequest) => void>() + const write = vi.fn<(message: string) => void>() + const exitListeners: Array<(exitCode: number) => void> = [] + const telemetry = createCommandTelemetry({ + env: {}, + ingestTarget: () => TARGET, + onExit: (listener) => exitListeners.push(listener), + send, + now: () => NOW, + elapsed: () => 1432.4, + stdoutIsTty: false, + stderrIsTty: false, + write, + ...overrides, + }) + const program = buildProgram() + telemetry.observe(program) + return { + telemetry, + program, + send, + write, + exit: (code: number) => exitListeners.forEach((l) => l(code)), + } +} + +async function run(program: Command, argv: string[]): Promise { + try { + await program.parseAsync(['node', 'sim', ...argv]) + return undefined + } catch (error) { + return error + } +} + +describe('command telemetry', () => { + it('reports the command path, typed flag names, and argument count — never values', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['--output', 'json', 'workflows', 'list', '--limit', '5', '--all']) + telemetry.complete({ exitCode: 0 }) + + expect(send).toHaveBeenCalledOnce() + const request = sentBy(send) + expect(request.event).toBe(COMMAND_EVENT) + expect(request.api_key).toBe('phc_test') + expect(request.timestamp).toBe(NOW.toISOString()) + expect(request.properties).toMatchObject({ + $lib: 'sim-cli', + $lib_version: CLI_VERSION, + $process_person_profile: false, + session_sequence: 1, + surface: 'cli', + command: 'workflows list', + flags: expect.arrayContaining(['--limit', '--all', '--output']), + arg_count: 0, + exit_code: 0, + duration_ms: 1432, + cli_version: CLI_VERSION, + node_version: process.versions.node, + os: process.platform, + arch: process.arch, + is_tty: false, + is_ci: false, + endpoint_kind: 'hosted', + }) + expect(JSON.stringify(request)).not.toContain('json') + expect(JSON.stringify(request)).not.toContain('"5"') + }) + + it('counts positional arguments without recording them', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['workflows', 'get', 'wf_secret_id']) + telemetry.complete({ exitCode: 0 }) + + expect(sentBy(send).properties).toMatchObject({ command: 'workflows get', arg_count: 1 }) + expect(JSON.stringify(sentBy(send))).not.toContain('wf_secret_id') + }) + + it('records a failure by class, code, and status, never by message', async () => { + const { telemetry, program, send } = harness() + + const error = await run(program, ['workflows', 'fail']) + telemetry.complete({ exitCode: 1, error }) + + expect(sentBy(send).properties).toMatchObject({ + exit_code: 1, + error_name: 'SimApiError', + error_code: 'NOT_FOUND', + http_status: 404, + }) + expect(JSON.stringify(sentBy(send))).not.toContain('Not found') + }) + + it('ties commands on one device into a numbered session', async () => { + const first = harness() + await run(first.program, ['workflows', 'list']) + first.telemetry.complete({ exitCode: 0 }) + + const second = harness() + await run(second.program, ['workflows', 'get', 'wf_1']) + second.telemetry.complete({ exitCode: 0 }) + + const [a, b] = [sentBy(first.send), sentBy(second.send)] + expect(a.distinct_id).toBe(b.distinct_id) + expect(a.properties.$session_id).toBe(b.properties.$session_id) + expect(b.properties.session_sequence).toBe(2) + expect(readTelemetryState(statePath)?.session?.sequence).toBe(2) + }) + + it('shows the notice when only stdout is piped, as in `sim … | jq`', async () => { + const { telemetry, program, send, write } = harness({ stdoutIsTty: false, stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(write).toHaveBeenCalledWith(FIRST_RUN_NOTICE) + expect(send).not.toHaveBeenCalled() + }) + + it('honours an opt-out saved while the command was running', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['workflows', 'list']) + writeTelemetryState({ ...loadTelemetryState(statePath), enabled: false }, statePath) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + expect(readTelemetryState(statePath)?.enabled).toBe(false) + }) + + it('reports the coding agent driving the shell', async () => { + const { telemetry, program, send } = harness({ env: { CLAUDECODE: '1' } }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(sentBy(send).properties.coding_agent).toBe('claude-code') + }) + + it('shows the first-run notice on a terminal and does not report that run', async () => { + const { telemetry, program, send, write } = harness({ stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(write).toHaveBeenCalledWith(FIRST_RUN_NOTICE) + expect(send).not.toHaveBeenCalled() + expect(readTelemetryState(statePath)?.noticeShownAt).toBe(NOW.toISOString()) + + const next = harness({ stderrIsTty: true }) + await run(next.program, ['workflows', 'list']) + next.telemetry.complete({ exitCode: 0 }) + + expect(next.write).not.toHaveBeenCalled() + expect(next.send).toHaveBeenCalledOnce() + }) + + it('shows no notice when stderr is redirected or in CI, and still reports', async () => { + const piped = harness({ stdoutIsTty: true, stderrIsTty: false }) + await run(piped.program, ['workflows', 'list']) + piped.telemetry.complete({ exitCode: 0 }) + + expect(piped.write).not.toHaveBeenCalled() + expect(piped.send).toHaveBeenCalledOnce() + + const ci = harness({ stderrIsTty: true, env: { CI: 'true' } }) + await run(ci.program, ['workflows', 'list']) + ci.telemetry.complete({ exitCode: 0 }) + + expect(ci.write).not.toHaveBeenCalled() + expect(sentBy(ci.send).properties.is_ci).toBe(true) + }) + + it.each([ + ['DO_NOT_TRACK', { DO_NOT_TRACK: '1' }], + ['SIM_TELEMETRY_DISABLED', { SIM_TELEMETRY_DISABLED: '1' }], + ])('reports nothing when %s is set', async (_name, env) => { + const { telemetry, program, send, write } = harness({ env, stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() + }) + + it('reports nothing after sim telemetry disable', async () => { + writeTelemetryState({ ...loadTelemetryState(statePath), enabled: false }, statePath) + const { telemetry, program, send } = harness() + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + }) + + it('reports nothing from a build with no destination', async () => { + const { telemetry, program, send, write } = harness({ + ingestTarget: () => undefined, + stderrIsTty: true, + }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() + }) + + it('never reports the telemetry commands themselves', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['telemetry', 'disable']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + }) + + it('reports from the process exit event, once, with the real exit code', async () => { + const { telemetry, program, send, exit } = harness() + + await run(program, ['workflows', 'list']) + exit(3) + exit(3) + telemetry.complete({ exitCode: 0 }) + + expect(send).toHaveBeenCalledOnce() + expect(sentBy(send).properties.exit_code).toBe(3) + }) + + it('never touches the state file when reporting is switched off', async () => { + const { telemetry, program } = harness({ env: { DO_NOT_TRACK: '1' }, stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(existsSync(statePath)).toBe(false) + }) + + it('reports nothing when no command ran', () => { + const { telemetry, send } = harness() + + telemetry.complete({ exitCode: 1, error: new Error('usage') }) + + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/telemetry/invocation.ts b/packages/sim-cli/src/telemetry/invocation.ts new file mode 100644 index 00000000000..1370cf73aa7 --- /dev/null +++ b/packages/sim-cli/src/telemetry/invocation.ts @@ -0,0 +1,306 @@ +import type { Command } from 'commander' +import { profileFrom } from '../context' +import { isCi } from '../environment' +import { SimApiError } from '../http/client' +import { CLI_VERSION } from '../version' +import { detectCodingAgent } from './coding-agent' +import { telemetryStatus } from './policy' +import { loadTelemetryState, nextSession, type TelemetryState, writeTelemetryState } from './state' +import { + builtInIngestTarget, + type CaptureRequest, + type IngestTarget, + sendCapture, +} from './transport' + +/** + * Usage reporting for one CLI invocation: what ran, whether it worked, and + * how long it took. One event per command, sent after the command finishes. + * + * What is sent is the smallest set that answers "how is the CLI used": the + * command's name, the names of the flags typed, how many positional arguments + * there were, the exit code, the duration, and the runtime it ran on. What is + * never sent is anything the user typed — no argument values, flag values, + * paths, or error messages — matching the policy the Stripe, GitHub, and + * Supabase CLIs converge on. See {@link CommandEventProperties}. + */ + +export const COMMAND_EVENT = 'cli_command_executed' + +/** The user-facing name of this reporting, as `$lib` in each event. */ +const LIBRARY_NAME = 'sim-cli' + +/** The command group that manages reporting is never itself reported. */ +const EXCLUDED_ROOT_COMMAND = 'telemetry' + +export const USAGE_DATA_DOCS_URL = 'https://docs.sim.ai/cli/usage-data' + +/** + * Printed once, the first time reporting would happen on an interactive + * terminal, the way the Vercel and Next.js notices are. The run that shows it + * is not reported, so nothing leaves the machine before the user has read + * that something will. + */ +export const FIRST_RUN_NOTICE = [ + 'Sim collects anonymous usage data to improve the CLI: which commands run, whether', + 'they succeed, and how long they take. Nothing you type is sent.', + `Learn more: ${USAGE_DATA_DOCS_URL}`, + 'Turn it off: sim telemetry disable', + '', +].join('\n') + +/** + * The event's properties. Kept in one place so the documentation page can be + * checked against it and a new property is a deliberate addition here. + */ +export interface CommandEventProperties { + /** Analytics library conventions: the reporting client and its version. */ + $lib: typeof LIBRARY_NAME + $lib_version: string + /** Events are anonymous and must not create a person profile per device. */ + $process_person_profile: false + /** Commands close in time share a session, so a sequence of commands can be read back. */ + $session_id: string + /** The command's position within its session. */ + session_sequence: number + /** The same surface name the server stamps on requests carrying `X-Sim-Client-Info`. */ + surface: 'cli' + /** The command's path, such as `workflows list` — never its arguments. */ + command: string + /** The long names of flags that were typed, such as `--output`; never their values. */ + flags: string[] + /** How many positional arguments were given; never what they were. */ + arg_count: number + exit_code: number + /** Time from process start to completion, in milliseconds. */ + duration_ms: number + /** The failure's class name, such as `SimApiError`; never its message. */ + error_name?: string + /** The API's machine-readable error code, such as `NOT_FOUND`. */ + error_code?: string + http_status?: number + cli_version: string + node_version: string + os: string + arch: string + /** Whether stdout was a terminal, which separates people from scripts. */ + is_tty: boolean + is_ci: boolean + /** The AI coding agent driving this shell, when one could be detected. */ + coding_agent?: string + /** Whether the profile targets Sim's hosted deployment or a self-hosted one; never the address. */ + endpoint_kind?: 'hosted' | 'self_hosted' +} + +export interface InvocationOutcome { + exitCode: number + error?: unknown +} + +export interface CommandTelemetryOptions { + env?: NodeJS.ProcessEnv + ingestTarget?: () => IngestTarget | undefined + send?: typeof sendCapture + now?: () => Date + /** Milliseconds since the process started, for the duration. */ + elapsed?: () => number + /** Whether stdout is a terminal: the `is_tty` property, which separates people from scripts. */ + stdoutIsTty?: boolean + /** Whether stderr is a terminal: where the notice would go, so whether anyone would see it. */ + stderrIsTty?: boolean + /** Where the first-run notice goes; stderr, so piped output stays clean. */ + write?: (message: string) => void + /** Registers the listener that reports when the process ends; the real process by default. */ + onExit?: (listener: (exitCode: number) => void) => void +} + +export interface CommandTelemetry { + /** Installs the hook that records which command is about to run, and the exit listener that reports it. */ + observe(program: Command): void + /** + * Reports the recorded command, given how it ended. Idempotent, and a no-op + * when nothing was recorded: the entrypoint calls it with the failure it + * explained, and the exit listener calls it for every other way out. + */ + complete(outcome: InvocationOutcome): void +} + +interface RecordedInvocation { + action: Command + command: string + flags: string[] + argCount: number + state: TelemetryState + /** Set when this run printed the first-run notice and is therefore not reported. */ + noticeShown: boolean +} + +/** The command's own name and its ancestors', root excluded, in typing order. */ +function commandPath(command: Command): string[] { + const names: string[] = [] + for (let current: Command | null = command; current?.parent; current = current.parent) { + names.unshift(current.name()) + } + return names +} + +/** + * The flags typed on the command line, on the leaf and every ancestor, so + * root globals like `--output` count. Only source `cli`: a value that came + * from the environment or a default was not something the user typed here. + */ +function typedFlags(command: Command): string[] { + const flags = new Set() + for (let current: Command | null = command; current; current = current.parent) { + for (const option of current.options) { + if (current.getOptionValueSource(option.attributeName()) !== 'cli') continue + const name = option.long ?? option.short + if (name) flags.add(name) + } + } + return [...flags] +} + +/** Whether the profile points at Sim's hosted deployment; `undefined` when no profile resolves. */ +function endpointKind(command: Command): CommandEventProperties['endpoint_kind'] | undefined { + try { + const hostname = new URL(profileFrom(command).endpoint).hostname + return hostname === 'sim.ai' || hostname.endsWith('.sim.ai') ? 'hosted' : 'self_hosted' + } catch { + return undefined + } +} + +function failureProperties( + error: unknown +): Pick { + if (error === undefined) return {} + if (!(error instanceof Error)) return { error_name: 'unknown' } + const properties: ReturnType = { error_name: error.name } + if (error instanceof SimApiError) { + if (error.code) properties.error_code = error.code + if (error.status > 0) properties.http_status = error.status + } + return properties +} + +const listenForProcessExit = (listener: (exitCode: number) => void): void => { + process.once('exit', listener) +} + +export function createCommandTelemetry(options: CommandTelemetryOptions = {}): CommandTelemetry { + const env = options.env ?? process.env + const ingestTarget = options.ingestTarget ?? builtInIngestTarget + const send = options.send ?? sendCapture + const now = options.now ?? (() => new Date()) + const elapsed = options.elapsed ?? (() => performance.now()) + const stdoutIsTty = options.stdoutIsTty ?? process.stdout.isTTY === true + const stderrIsTty = options.stderrIsTty ?? process.stderr.isTTY === true + const write = options.write ?? ((message: string) => void process.stderr.write(message)) + const onExit = options.onExit ?? listenForProcessExit + + let recorded: RecordedInvocation | undefined + + /** + * Whether this run could report at all, decided from the environment and the + * build alone so a run that cannot report never touches the state file. + */ + function isReportable(state: Pick): boolean { + return telemetryStatus({ env, state, configured: ingestTarget() !== undefined }).enabled + } + + /** + * Shows the notice on the first interactive run that would report, and + * remembers having done so. Gated on stderr, where it is written: not in CI, + * where nobody is reading, and not when stderr is redirected, where it would + * land in a log — while `sim … | jq`, which redirects only stdout, still + * shows it. + */ + function showNoticeIfDue(state: TelemetryState): boolean { + if (state.noticeShownAt || !stderrIsTty || isCi(env)) return false + write(FIRST_RUN_NOTICE) + writeTelemetryState({ ...state, noticeShownAt: now().toISOString() }) + return true + } + + function complete(outcome: InvocationOutcome): void { + const invocation = recorded + recorded = undefined + if (!invocation || invocation.noticeShown) return + const target = ingestTarget() + if (!target) return + + /** + * Re-read rather than reuse the snapshot from before the command ran: an + * opt-out saved meanwhile — `sim telemetry disable` in another terminal, + * during a long command — must win, and the write below must not put the + * stale snapshot back over it. + */ + const state = loadTelemetryState() + if (!isReportable(state)) return + + const timestamp = now() + const session = nextSession(state, timestamp) + writeTelemetryState({ ...state, session }) + + const properties: CommandEventProperties = { + $lib: LIBRARY_NAME, + $lib_version: CLI_VERSION, + $process_person_profile: false, + $session_id: session.id, + session_sequence: session.sequence, + surface: 'cli', + command: invocation.command, + flags: invocation.flags, + arg_count: invocation.argCount, + exit_code: outcome.exitCode, + duration_ms: Math.round(elapsed()), + ...failureProperties(outcome.error), + cli_version: CLI_VERSION, + node_version: process.versions.node, + os: process.platform, + arch: process.arch, + is_tty: stdoutIsTty, + is_ci: isCi(env), + } + const kind = endpointKind(invocation.action) + if (kind) properties.endpoint_kind = kind + const agent = detectCodingAgent(env) + if (agent) properties.coding_agent = agent + + send(target, { + api_key: target.key, + event: COMMAND_EVENT, + distinct_id: state.deviceId, + timestamp: timestamp.toISOString(), + properties, + } satisfies CaptureRequest) + } + + return { + observe(program) { + program.hook('preAction', (_root, action) => { + const path = commandPath(action) + if (path[0] === EXCLUDED_ROOT_COMMAND || !isReportable({})) return + const state = loadTelemetryState() + if (!isReportable(state)) return + recorded = { + action, + command: path.join(' '), + flags: typedFlags(action), + argCount: action.args.length, + state, + noticeShown: showNoticeIfDue(state), + } + }) + /** + * Commands end in more ways than one: a handler that calls `process.exit` + * itself, a `process.exitCode` set on the way out, or the entrypoint's + * own exit. Reporting from the exit event sees all of them, and the + * sender is a synchronous spawn, which is what an exit listener allows. + */ + onExit((exitCode) => complete({ exitCode })) + }, + complete, + } +} diff --git a/packages/sim-cli/src/telemetry/policy.test.ts b/packages/sim-cli/src/telemetry/policy.test.ts new file mode 100644 index 00000000000..acfd18f5990 --- /dev/null +++ b/packages/sim-cli/src/telemetry/policy.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { telemetryStatus } from './policy' + +describe('telemetryStatus', () => { + it('is on when nothing turns it off and the build can report', () => { + expect(telemetryStatus({ env: {}, state: {}, configured: true })).toEqual({ enabled: true }) + }) + + it.each(['1', 'true', 'TRUE', 'yes'])('honours DO_NOT_TRACK=%s before anything else', (value) => { + expect( + telemetryStatus({ env: { DO_NOT_TRACK: value }, state: { enabled: true }, configured: true }) + ).toEqual({ enabled: false, reason: 'do_not_track' }) + }) + + it.each(['0', 'false', ''])('ignores DO_NOT_TRACK=%s', (value) => { + expect(telemetryStatus({ env: { DO_NOT_TRACK: value }, state: {}, configured: true })).toEqual({ + enabled: true, + }) + }) + + it('names the environment switch ahead of the saved setting', () => { + expect( + telemetryStatus({ + env: { SIM_TELEMETRY_DISABLED: '1' }, + state: { enabled: false }, + configured: true, + }) + ).toEqual({ enabled: false, reason: 'environment' }) + }) + + it('names the saved setting ahead of a missing destination', () => { + expect(telemetryStatus({ env: {}, state: { enabled: false }, configured: false })).toEqual({ + enabled: false, + reason: 'setting', + }) + }) + + it('is off in a build with no destination', () => { + expect(telemetryStatus({ env: {}, state: {}, configured: false })).toEqual({ + enabled: false, + reason: 'unconfigured', + }) + }) +}) diff --git a/packages/sim-cli/src/telemetry/policy.ts b/packages/sim-cli/src/telemetry/policy.ts new file mode 100644 index 00000000000..2ba9979643b --- /dev/null +++ b/packages/sim-cli/src/telemetry/policy.ts @@ -0,0 +1,48 @@ +import { isEnabled } from '../environment' +import type { TelemetryState } from './state' + +/** + * The switch every CLI that reports usage honours before its own: `DO_NOT_TRACK=1` + * expresses a lack of consent to any usage reporting, from any tool. Spec at + * consoledonottrack.com; `true` is accepted as well, as Turborepo, Wrangler, + * and the GitHub CLI do. + */ +export const DO_NOT_TRACK_VARIABLE = 'DO_NOT_TRACK' + +/** The CLI's own switch, for turning reporting off in one environment or CI job. */ +export const TELEMETRY_DISABLED_VARIABLE = 'SIM_TELEMETRY_DISABLED' + +export type TelemetryDisabledReason = + /** `DO_NOT_TRACK` is set. */ + | 'do_not_track' + /** `SIM_TELEMETRY_DISABLED` is set. */ + | 'environment' + /** The user ran `sim telemetry disable`. */ + | 'setting' + /** This build was made without a reporting destination, so there is nowhere to send to. */ + | 'unconfigured' + +export type TelemetryStatus = + | { enabled: true } + | { enabled: false; reason: TelemetryDisabledReason } + +export interface TelemetryStatusInput { + env: NodeJS.ProcessEnv + state: Pick + /** Whether the build carries a reporting destination. */ + configured: boolean +} + +/** + * Whether usage reporting is on, and if not, the first reason that turns it + * off. The order is the order of authority: the universal opt-out, then the + * environment, then the saved setting, then whether this build can report at + * all — so `sim telemetry status` names the reason the user can act on. + */ +export function telemetryStatus({ env, state, configured }: TelemetryStatusInput): TelemetryStatus { + if (isEnabled(env[DO_NOT_TRACK_VARIABLE])) return { enabled: false, reason: 'do_not_track' } + if (isEnabled(env[TELEMETRY_DISABLED_VARIABLE])) return { enabled: false, reason: 'environment' } + if (state.enabled === false) return { enabled: false, reason: 'setting' } + if (!configured) return { enabled: false, reason: 'unconfigured' } + return { enabled: true } +} diff --git a/packages/sim-cli/src/telemetry/state.test.ts b/packages/sim-cli/src/telemetry/state.test.ts new file mode 100644 index 00000000000..c5615dca04d --- /dev/null +++ b/packages/sim-cli/src/telemetry/state.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + loadTelemetryState, + nextSession, + readTelemetryState, + SESSION_IDLE_MS, + type TelemetryState, + writeTelemetryState, +} from './state' + +let dir: string +let path: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-telemetry-')) + path = join(dir, 'telemetry.json') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe('telemetry state', () => { + it('mints a fresh device id when there is no file, without writing one', () => { + const state = loadTelemetryState(path) + + expect(state.deviceId).toMatch(UUID) + expect(() => statSync(path)).toThrow() + }) + + it('round-trips through the file, readable by the owner only', () => { + const state: TelemetryState = { + version: 1, + deviceId: 'device-1', + enabled: false, + noticeShownAt: '2026-09-10T00:00:00.000Z', + session: { id: 'session-1', lastActiveAt: '2026-09-10T00:00:00.000Z', sequence: 3 }, + } + + writeTelemetryState(state, path) + + expect(readTelemetryState(path)).toEqual(state) + expect(statSync(path).mode & 0o777).toBe(0o600) + expect(readFileSync(path, 'utf8').endsWith('\n')).toBe(true) + }) + + it.each([ + ['not json', 'nope'], + ['an unknown version', JSON.stringify({ version: 2, deviceId: 'd' })], + ['a missing device id', JSON.stringify({ version: 1 })], + ['an empty device id', JSON.stringify({ version: 1, deviceId: '' })], + ])('treats %s as absent', (_label, content) => { + writeFileSync(path, content) + + expect(readTelemetryState(path)).toBeNull() + expect(loadTelemetryState(path).deviceId).toMatch(UUID) + }) + + it('drops a malformed session or notice stamp but keeps the device id', () => { + writeFileSync( + path, + JSON.stringify({ + version: 1, + deviceId: 'device-1', + noticeShownAt: 'yesterday', + session: { id: 'session-1', lastActiveAt: 'never', sequence: -1 }, + }) + ) + + expect(readTelemetryState(path)).toEqual({ version: 1, deviceId: 'device-1' }) + }) +}) + +describe('nextSession', () => { + const state: TelemetryState = { version: 1, deviceId: 'device-1' } + const now = new Date('2026-09-10T12:00:00.000Z') + + it('starts a first session at sequence one', () => { + const session = nextSession(state, now) + + expect(session).toEqual({ + id: expect.stringMatching(UUID), + lastActiveAt: now.toISOString(), + sequence: 1, + }) + }) + + it('continues a session that was active within the idle window', () => { + const recent = new Date(now.getTime() - SESSION_IDLE_MS + 1000) + const session = nextSession( + { ...state, session: { id: 'session-1', lastActiveAt: recent.toISOString(), sequence: 4 } }, + now + ) + + expect(session).toEqual({ id: 'session-1', lastActiveAt: now.toISOString(), sequence: 5 }) + }) + + it('starts a new session after the idle window', () => { + const stale = new Date(now.getTime() - SESSION_IDLE_MS) + const session = nextSession( + { ...state, session: { id: 'session-1', lastActiveAt: stale.toISOString(), sequence: 4 } }, + now + ) + + expect(session.id).not.toBe('session-1') + expect(session.sequence).toBe(1) + }) + + it('starts a new session when the clock has moved backwards', () => { + const future = new Date(now.getTime() + 60_000) + const session = nextSession( + { ...state, session: { id: 'session-1', lastActiveAt: future.toISOString(), sequence: 4 } }, + now + ) + + expect(session.id).not.toBe('session-1') + }) +}) diff --git a/packages/sim-cli/src/telemetry/state.ts b/packages/sim-cli/src/telemetry/state.ts new file mode 100644 index 00000000000..57b360245ac --- /dev/null +++ b/packages/sim-cli/src/telemetry/state.ts @@ -0,0 +1,109 @@ +import { generateId } from '@sim/utils/id' +import { readJsonFile, writeJsonFile } from '../config/json-file' +import { telemetryStatePath } from '../config/paths' + +/** + * Two commands closer together than this belong to one session, the way the + * Vercel and Supabase CLIs group them. Far above a person's pause between + * commands and far below a lunch break, so a session reads as one sitting. + */ +export const SESSION_IDLE_MS = 30 * 60 * 1000 + +/** Far above the few-hundred-byte document while still bounding hostile files. */ +const MAX_STATE_BYTES = 4 * 1024 + +const STATE_VERSION = 1 + +/** The state file is not secret, but it identifies the device, so it is the user's alone. */ +const STATE_FILE_MODE = 0o600 + +export interface TelemetrySession { + id: string + /** When the last command in this session ran, for the idle cutoff. */ + lastActiveAt: string + /** Commands recorded in this session so far; the next one is `sequence + 1`. */ + sequence: number +} + +export interface TelemetryState { + /** Unknown versions are treated as absent. */ + version: typeof STATE_VERSION + /** + * A random id for this installation, minted the first time telemetry runs. + * It ties one device's commands together and nothing else: it is not derived + * from hardware, the account, or the network. + */ + deviceId: string + /** `false` after `sim telemetry disable`; absent or `true` otherwise. */ + enabled?: boolean + /** When the first-run notice was printed; absent until it has been. */ + noticeShownAt?: string + session?: TelemetrySession +} + +function isIsoTimestamp(value: unknown): value is string { + return typeof value === 'string' && !Number.isNaN(Date.parse(value)) +} + +function parseSession(value: unknown): TelemetrySession | undefined { + if (typeof value !== 'object' || value === null) return undefined + const session = value as Partial + if (typeof session.id !== 'string' || !session.id) return undefined + if (!isIsoTimestamp(session.lastActiveAt)) return undefined + if (!Number.isSafeInteger(session.sequence) || (session.sequence as number) < 0) return undefined + return { + id: session.id, + lastActiveAt: session.lastActiveAt, + sequence: session.sequence as number, + } +} + +/** A fresh state for an installation telemetry has never seen. Not written until something changes. */ +function initialTelemetryState(): TelemetryState { + return { version: STATE_VERSION, deviceId: generateId() } +} + +/** + * The persisted state, or `null` when there is none worth trusting. + * + * Validated field by field: the file is the user's to edit or corrupt, and a + * document that fails validation is replaced rather than repaired, so a bad + * `deviceId` never leaks into an event. + */ +export function readTelemetryState(path = telemetryStatePath()): TelemetryState | null { + const parsed = readJsonFile(path, MAX_STATE_BYTES) + if (typeof parsed !== 'object' || parsed === null) return null + const state = parsed as Partial + if (state.version !== STATE_VERSION) return null + if (typeof state.deviceId !== 'string' || !state.deviceId) return null + + const result: TelemetryState = { version: STATE_VERSION, deviceId: state.deviceId } + if (typeof state.enabled === 'boolean') result.enabled = state.enabled + if (isIsoTimestamp(state.noticeShownAt)) result.noticeShownAt = state.noticeShownAt + const session = parseSession(state.session) + if (session) result.session = session + return result +} + +/** The persisted state, or a fresh one when there is none. */ +export function loadTelemetryState(path = telemetryStatePath()): TelemetryState { + return readTelemetryState(path) ?? initialTelemetryState() +} + +export function writeTelemetryState(state: TelemetryState, path = telemetryStatePath()): void { + writeJsonFile(path, state, STATE_FILE_MODE) +} + +/** + * The session the next command belongs to: the current one, advanced by one, + * when it was active within {@link SESSION_IDLE_MS}; otherwise a new one. A + * clock that moved backwards reads as idle, which only starts a new session. + */ +export function nextSession(state: TelemetryState, now: Date): TelemetrySession { + const current = state.session + const idleFor = current ? now.getTime() - Date.parse(current.lastActiveAt) : Number.NaN + if (current && idleFor >= 0 && idleFor < SESSION_IDLE_MS) { + return { id: current.id, lastActiveAt: now.toISOString(), sequence: current.sequence + 1 } + } + return { id: generateId(), lastActiveAt: now.toISOString(), sequence: 1 } +} diff --git a/packages/sim-cli/src/telemetry/transport.test.ts b/packages/sim-cli/src/telemetry/transport.test.ts new file mode 100644 index 00000000000..2fb396a369d --- /dev/null +++ b/packages/sim-cli/src/telemetry/transport.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type CaptureRequest, + DEFAULT_INGEST_HOST, + type SpawnSender, + sendCapture, +} from './transport' + +const request: CaptureRequest = { + api_key: 'phc_test', + event: 'cli_command_executed', + distinct_id: 'device-1', + timestamp: '2026-09-10T12:00:00.000Z', + properties: { command: 'workflows list' }, +} + +function fakeSpawn() { + const child = { unref: vi.fn(), once: vi.fn() } + const spawn = vi.fn(() => child) + return { spawn, child } +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('sendCapture', () => { + it('hands the event to a detached sender and lets go of it', () => { + const { spawn, child } = fakeSpawn() + + sendCapture({ key: 'phc_test', host: DEFAULT_INGEST_HOST }, request, spawn) + + expect(spawn).toHaveBeenCalledOnce() + const [command, args, options] = spawn.mock.calls[0] + expect(command).toBe(process.execPath) + expect(args).toContain('--input-type=module') + expect(options).toMatchObject({ detached: true, stdio: 'ignore', windowsHide: true }) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('addresses the capture endpoint on the target host and carries the event in the environment', () => { + const { spawn } = fakeSpawn() + + sendCapture({ key: 'phc_test', host: 'https://eu.i.posthog.com' }, request, spawn) + + const payload = JSON.parse(spawn.mock.calls[0][2].env.SIM_TELEMETRY_CAPTURE as string) + expect(payload.url).toBe('https://eu.i.posthog.com/i/v0/e/') + expect(JSON.parse(payload.body)).toEqual(request) + expect(payload.timeoutMs).toBeGreaterThan(0) + }) + + it('never hands the sender the API key', () => { + vi.stubEnv('SIM_API_KEY', 'sim_secret') + vi.stubEnv('HTTPS_PROXY', 'http://proxy.internal:3128') + const { spawn } = fakeSpawn() + + sendCapture({ key: 'phc_test', host: DEFAULT_INGEST_HOST }, request, spawn) + + const env = spawn.mock.calls[0][2].env + expect(env.SIM_API_KEY).toBeUndefined() + expect(env.HTTPS_PROXY).toBe('http://proxy.internal:3128') + }) + + it('swallows a sender that cannot start', () => { + const spawn = vi.fn(() => { + throw new Error('spawn EAGAIN') + }) + + expect(() => + sendCapture({ key: 'phc_test', host: DEFAULT_INGEST_HOST }, request, spawn) + ).not.toThrow() + }) +}) diff --git a/packages/sim-cli/src/telemetry/transport.ts b/packages/sim-cli/src/telemetry/transport.ts new file mode 100644 index 00000000000..32d35dca2e0 --- /dev/null +++ b/packages/sim-cli/src/telemetry/transport.ts @@ -0,0 +1,113 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { childProcessEnv, proxyExecArgv } from '../environment' + +/** + * Where events go. The key is a PostHog project token — a public, write-only + * value — baked into the published build by `bun build --env='SIM_CLI_TELEMETRY_*'` + * the way the Supabase CLI injects its own at link time. A checkout built + * without one has no destination and reports nothing, and a self-hosted + * deployment can point its own build at its own project. + * + * These two reads must stay literal `process.env.` expressions: that is + * the only form the bundler substitutes. + */ +const BUILT_IN_KEY = process.env.SIM_CLI_TELEMETRY_KEY +const BUILT_IN_HOST = process.env.SIM_CLI_TELEMETRY_HOST + +export const DEFAULT_INGEST_HOST = 'https://us.i.posthog.com' + +/** The single-event capture endpoint, relative to the ingest host. */ +const CAPTURE_PATH = '/i/v0/e/' + +/** + * How long the sender may take. Generous for a request that carries a kilobyte, + * and irrelevant to the user, who has already got their prompt back. + */ +const SEND_TIMEOUT_MS = 5000 + +/** Carries the request into the sender process; not part of the bundled build's env glob. */ +const PAYLOAD_VARIABLE = 'SIM_TELEMETRY_CAPTURE' + +export interface IngestTarget { + key: string + host: string +} + +/** The destination this build was made with, if any. */ +export function builtInIngestTarget(): IngestTarget | undefined { + if (!BUILT_IN_KEY) return undefined + return { key: BUILT_IN_KEY, host: BUILT_IN_HOST || DEFAULT_INGEST_HOST } +} + +/** One event in the shape PostHog's capture endpoint accepts. */ +export interface CaptureRequest> { + api_key: string + event: string + distinct_id: string + timestamp: string + properties: Properties +} + +const SEND_SCRIPT = ` +try { + const { url, body, timeoutMs } = JSON.parse(process.env[${JSON.stringify(PAYLOAD_VARIABLE)}]) + const deadline = setTimeout(() => process.exit(1), timeoutMs) + await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + redirect: 'error', + }) + clearTimeout(deadline) + process.exit(0) +} catch { + process.exit(1) +} +` + +export type SpawnSender = ( + command: string, + args: readonly string[], + options: { detached: boolean; env: NodeJS.ProcessEnv; stdio: 'ignore'; windowsHide: boolean } +) => Pick + +/** + * Sends one event from a process whose lifetime is its own. + * + * The command has finished and the user has their prompt back; nothing about + * delivery should change that. An in-process request would keep the event loop + * alive for as long as the network took to answer — a DNS lookup on a dead + * network cannot be cancelled at all — so the request is made by a detached + * child that the parent does not wait for, the way the GitHub and Vercel CLIs + * send theirs. The child bounds its own life with a deadline. The request + * travels in the child's environment rather than on argv because the + * environment is handed over at spawn time, so the parent can exit at once + * without a pipe left half-written. + */ +export function sendCapture( + target: IngestTarget, + request: CaptureRequest, + spawnSender: SpawnSender = spawn +): void { + const payload = JSON.stringify({ + url: new URL(CAPTURE_PATH, target.host).href, + body: JSON.stringify(request), + timeoutMs: SEND_TIMEOUT_MS, + }) + try { + const child = spawnSender( + process.execPath, + [...proxyExecArgv(), '--input-type=module', '--eval', SEND_SCRIPT], + { + detached: true, + env: childProcessEnv(['sim_api_key'], { [PAYLOAD_VARIABLE]: payload }), + stdio: 'ignore', + windowsHide: true, + } + ) + child.once('error', () => {}) + child.unref() + } catch { + /** A sender that could not start is an event not worth the command noticing. */ + } +} diff --git a/packages/sim-cli/src/update/check.process.test.ts b/packages/sim-cli/src/update/check.process.test.ts index 03972c4c5d1..542e69b5543 100644 --- a/packages/sim-cli/src/update/check.process.test.ts +++ b/packages/sim-cli/src/update/check.process.test.ts @@ -304,9 +304,7 @@ it('preserves a mirror path, query, and reduced request headers', async () => { expect(result).toMatchObject({ code: 0, signal: null, stderr: '' }) const output = JSON.parse(result.stdout) as CheckOutput - expect(output.notices).toEqual([ - 'Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest\n', - ]) + expect(output.notices).toEqual(['Update available: sim 2.1.2 → 2.1.5. Run: sim update\n']) expect(requestPath).toBe('/api/npm/repo/-/package/sim/dist-tags?token=abc') expect(requestHeaders).toMatchObject({ accept: 'application/json', diff --git a/packages/sim-cli/src/update/check.test.ts b/packages/sim-cli/src/update/check.test.ts index 9400d1f6a43..b899dc50d7e 100644 --- a/packages/sim-cli/src/update/check.test.ts +++ b/packages/sim-cli/src/update/check.test.ts @@ -79,9 +79,7 @@ afterEach(() => { describe('announcing a newer release', () => { it('names both versions and the command that closes the gap', async () => { await run() - expect(notices.join('')).toBe( - 'Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest\n' - ) + expect(notices.join('')).toBe('Update available: sim 2.1.2 → 2.1.5. Run: sim update\n') }) it('asks the registry for the dist-tags and nothing else', async () => { diff --git a/packages/sim-cli/src/update/check.ts b/packages/sim-cli/src/update/check.ts index 900ef4752e7..fede0442035 100644 --- a/packages/sim-cli/src/update/check.ts +++ b/packages/sim-cli/src/update/check.ts @@ -6,26 +6,15 @@ * in 2.1.5 — sees a help listing without it and concludes the CLI cannot do it. * The version is the only thing that can tell them otherwise. * - * Everything here fails silently. A courtesy notice that breaks a command, or - * that writes anything to stdout, is worse than no notice at all. + * Registry and cache failures suppress the courtesy notice. Installation only + * happens when the user explicitly runs `sim update`. */ import { spawn } from 'node:child_process' -import { - closeSync, - constants, - fstatSync, - lstatSync, - mkdirSync, - openSync, - readSync, - renameSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { readJsonFile, writeJsonFile } from '../config/json-file' import { updateCachePath } from '../config/paths' +import { childProcessEnv, isCi, isEnabled, proxyExecArgv } from '../environment' import { CLI_VERSION } from '../version' /** How long a cached check suppresses another request. */ @@ -68,15 +57,6 @@ function isNewerVersion(candidate: StableVersion, current: StableVersion): boole return candidate[2] > current[2] } -/** Covers CI jobs that allocate a terminal despite being non-interactive. */ -const CI_VARIABLES = [ - 'CI', - 'GITHUB_ACTIONS', - 'JENKINS_URL', - 'TEAMCITY_VERSION', - 'BUILDKITE', -] as const - /** The shape written to the update cache. */ interface UpdateCacheEntry { /** Unknown cache versions are treated as absent. */ @@ -109,16 +89,6 @@ interface RegistryRequestOptions { type RegistryRequest = (url: URL, options: RegistryRequestOptions) => Promise -/** Makes adjacent temporary files unique across writes in this process. */ -let cacheWriteSequence = 0 - -/** Anything but unset, empty, `0` or `false` turns a switch on. */ -function isEnabled(value: string | undefined): boolean { - if (value === undefined) return false - const normalized = value.trim().toLowerCase() - return normalized !== '' && normalized !== '0' && normalized !== 'false' -} - /** Whether the package is installed in a node_modules tree above the working directory. */ function isProjectLocalInstall(modulePath: string, cwd: string): boolean { const normalizedModulePath = normalizeModulePath(modulePath) @@ -203,16 +173,6 @@ try { } ` -/** Preserves proxy/TLS settings without copying CLI credentials into the probe. */ -function registryProcessEnv(): NodeJS.ProcessEnv { - const env = { ...process.env } - for (const key of Object.keys(env)) { - const normalized = key.toLowerCase() - if (normalized === 'npm_config_registry' || normalized === 'sim_api_key') delete env[key] - } - return env -} - /** * Makes one request in a process whose lifetime is owned entirely by this check. * @@ -228,14 +188,11 @@ function requestRegistry( { headers, maxResponseBytes, timeoutMs }: RegistryRequestOptions ): Promise { return new Promise((resolve, reject) => { - const proxyArguments = process.execArgv.filter( - (argument) => argument === '--use-env-proxy' || argument === '--no-use-env-proxy' - ) const child = spawn( process.execPath, - [...proxyArguments, '--input-type=module', '--eval', REGISTRY_REQUEST_SCRIPT], + [...proxyExecArgv(), '--input-type=module', '--eval', REGISTRY_REQUEST_SCRIPT], { - env: registryProcessEnv(), + env: childProcessEnv(['npm_config_registry', 'sim_api_key']), killSignal: 'SIGKILL', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs, @@ -305,49 +262,12 @@ async function fetchDistTags( } function readCache(path: string): UpdateCacheEntry | null { - let descriptor: number | null = null - try { - if (!lstatSync(path).isFile()) return null - descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) - const descriptorStats = fstatSync(descriptor) - if (!descriptorStats.isFile() || descriptorStats.size > MAX_CACHE_BYTES) { - return null - } - - const buffer = Buffer.allocUnsafe(MAX_CACHE_BYTES + 1) - let bytesRead = 0 - while (bytesRead < buffer.byteLength) { - const count = readSync( - descriptor, - buffer, - bytesRead, - buffer.byteLength - bytesRead, - bytesRead - ) - if (count === 0) break - bytesRead += count - } - if (bytesRead > MAX_CACHE_BYTES) return null - - const parsed: unknown = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')) - if (typeof parsed !== 'object' || parsed === null) return null - const entry = parsed as Partial - if (entry.version !== CACHE_VERSION) return null - if (typeof entry.checkedAt !== 'string' || Number.isNaN(Date.parse(entry.checkedAt))) - return null - return { - version: CACHE_VERSION, - checkedAt: entry.checkedAt, - } - } catch { - return null - } finally { - if (descriptor !== null) { - try { - closeSync(descriptor) - } catch {} - } - } + const parsed = readJsonFile(path, MAX_CACHE_BYTES) + if (typeof parsed !== 'object' || parsed === null) return null + const entry = parsed as Partial + if (entry.version !== CACHE_VERSION) return null + if (typeof entry.checkedAt !== 'string' || Number.isNaN(Date.parse(entry.checkedAt))) return null + return { version: CACHE_VERSION, checkedAt: entry.checkedAt } } /** @@ -355,36 +275,9 @@ function readCache(path: string): UpdateCacheEntry | null { * * Stamping on failure too is what keeps a blackholed registry costing one second * a day instead of one second per command. - * - * Failures are ignored because the cache is best-effort. An exclusive adjacent - * temporary file makes replacement atomic without modifying a linked target. */ function writeCache(path: string, entry: UpdateCacheEntry): void { - let descriptor: number | null = null - let temporaryCreated = false - const temporaryPath = `${path}.${process.pid}.${Date.now()}.${cacheWriteSequence++}.tmp` - try { - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - descriptor = openSync(temporaryPath, 'wx', 0o644) - temporaryCreated = true - writeFileSync(descriptor, `${JSON.stringify(entry, null, 2)}\n`) - closeSync(descriptor) - descriptor = null - renameSync(temporaryPath, path) - temporaryCreated = false - } catch { - } finally { - if (descriptor !== null) { - try { - closeSync(descriptor) - } catch {} - } - if (temporaryCreated) { - try { - unlinkSync(temporaryPath) - } catch {} - } - } + writeJsonFile(path, entry) } /** Treats future timestamps as stale in case the clock moved backward. */ @@ -445,7 +338,7 @@ export async function announceUpdateIfAvailable(options: UpdateCheckOptions = {} if (isEnabled(env.SIM_NO_UPDATE_CHECK)) return if (!isTty) return - if (CI_VARIABLES.some((variable) => isEnabled(env[variable]))) return + if (isCi(env)) return if (isUnadvisableInstall(modulePath, env, cwd)) return const currentVersion = options.currentVersion ?? CLI_VERSION @@ -468,8 +361,6 @@ export async function announceUpdateIfAvailable(options: UpdateCheckOptions = {} if (!isNewerVersion(available, current)) return const write = options.write ?? ((message: string) => void process.stderr.write(message)) - write( - `Update available: sim ${currentVersion} → ${latest}. Run: ${upgradeCommand(modulePath, env)}\n` - ) + write(`Update available: sim ${currentVersion} → ${latest}. Run: sim update\n`) } catch {} } diff --git a/packages/sim-cli/src/update/install.process.test.ts b/packages/sim-cli/src/update/install.process.test.ts new file mode 100644 index 00000000000..3f9e3b3aecb --- /dev/null +++ b/packages/sim-cli/src/update/install.process.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +let directory: string +let entrypoint: string +let manifest: string +let bin: string +let modules: string + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-cli-update-process-')) + modules = join(directory, 'node_modules') + entrypoint = join(modules, 'sim/dist/index.js') + manifest = join(modules, 'sim/package.json') + bin = join(directory, 'bin') + mkdirSync(bin) + execFileSync('bun', [ + 'build', + fileURLToPath(new URL('../index.ts', import.meta.url)), + '--target=node', + '--format=esm', + '--packages=bundle', + '--outfile', + entrypoint, + ]) +}) + +beforeEach(() => { + writeFileSync(manifest, JSON.stringify({ name: 'sim', type: 'module', version: '2.1.2' })) +}) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +function fakePackageManager( + exitCode = 0, + version = '2.1.5', + manifestBody?: string, + globalDirectory = modules +): void { + const script = ` +if (process.env.SIM_API_KEY) throw new Error('Sim API key leaked to package manager') +const args = process.argv.slice(2) +if (args.join(' ') === 'root -g') { + process.stdout.write(${JSON.stringify(globalDirectory)}) +} else if (args.join(' ') === 'view sim@latest version --json') { + process.stdout.write(JSON.stringify(${JSON.stringify(version)})) +} else if (args.join(' ') === 'install -g sim@' + ${JSON.stringify(version)}) { + process.stdout.write('package manager stdout\\n') + process.stderr.write('package manager stderr\\n') + if (${exitCode} !== 0) process.exit(${exitCode}) + require('node:fs').writeFileSync(${JSON.stringify(manifest)}, ${JSON.stringify(manifestBody ?? JSON.stringify({ name: 'sim', type: 'module', version }))}) +} else { + throw new Error('Unexpected arguments: ' + args.join(' ')) +} +` + const executable = join(bin, 'npm') + writeFileSync(executable, `#!/usr/bin/env node\n${script}`) + chmodSync(executable, 0o755) +} + +function run(args: string[]) { + return spawnSync(process.execPath, [entrypoint, ...args], { + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH}`, + SIM_CONFIG_DIR: join(directory, 'no-profile'), + SIM_API_KEY: 'must-not-reach-installer', + SIM_NO_UPDATE_CHECK: '1', + npm_command: '', + npm_config_user_agent: '', + }, + }) +} + +describe.skipIf(process.platform === 'win32')('the bundled sim update command', () => { + it('runs without login, updates the package, and keeps stdout clean', () => { + fakePackageManager() + const result = run(['update']) + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('package manager stdout') + expect(result.stderr).toContain('package manager stderr') + expect(result.stderr).toContain('Updated Sim 2.1.2 → 2.1.5') + expect(run(['--version']).stdout.trim()).toBe('2.1.5') + }) + + it('exits unsuccessfully on an installer failure without printing success', () => { + fakePackageManager(17) + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('exit 17') + expect(result.stderr).not.toContain('Updated Sim') + expect(run(['--version']).stdout.trim()).toBe('2.1.2') + }) + + it('answers update help without invoking the installer', () => { + fakePackageManager(17) + const result = run(['update', '--help']) + expect(result.status).toBe(0) + expect(result.stdout).toContain('sim update') + expect(result.stdout).toContain('--package-manager') + expect(result.stderr).toBe('') + }) + + it('refuses an older registry release without running the installer', () => { + fakePackageManager(0, '2.1.1') + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Refusing to downgrade') + expect(result.stderr).not.toContain('package manager stdout') + expect(result.stderr).not.toMatch(/\n\s+at /) + expect(run(['--version']).stdout.trim()).toBe('2.1.2') + }) + + it('prints a clear failure for a missing global installation entry', () => { + fakePackageManager(0, '2.1.5', undefined, join(directory, 'missing')) + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Cannot access the Sim installation') + expect(result.stderr).not.toMatch(/\n\s+at /) + }) + + it('prints a clear failure when the installed manifest is malformed', () => { + fakePackageManager(0, '2.1.5', '{') + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Cannot read the installed Sim manifest') + expect(result.stderr).not.toContain('Updated Sim') + expect(result.stderr).not.toMatch(/\n\s+at /) + }) + + it('prints a clear failure for a concurrent update', () => { + fakePackageManager() + const lockDirectory = join(modules, 'sim.lock') + mkdirSync(lockDirectory) + try { + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Cannot lock Sim for update') + expect(result.stderr).not.toMatch(/\n\s+at /) + } finally { + rmSync(lockDirectory, { recursive: true }) + } + }) +}) diff --git a/packages/sim-cli/src/update/install.test.ts b/packages/sim-cli/src/update/install.test.ts new file mode 100644 index 00000000000..c452a4b1aba --- /dev/null +++ b/packages/sim-cli/src/update/install.test.ts @@ -0,0 +1,401 @@ +/** + * @vitest-environment node + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CliUpdateError, installUpdate, type PackageManager } from '#sim-cli/update/install' + +let directory: string +let packageRoot: string +let modulePath: string +let output: string[] + +function writeVersion(version: string, root = packageRoot): void { + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'sim', version })) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-cli-install-update-')) + packageRoot = join(directory, 'node_modules/sim') + modulePath = join(packageRoot, 'dist/index.js') + mkdirSync(dirname(modulePath), { recursive: true }) + writeFileSync(modulePath, '') + writeVersion('2.1.2') + output = [] +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +function options() { + return { + modulePath, + env: {}, + currentVersion: '2.1.2', + write: (message: string) => output.push(message), + } +} + +describe('installing a CLI update', () => { + it.each(['npm', 'pnpm', 'bun', 'yarn'])( + 'updates the active global copy with %s', + async (packageManager) => { + const bin = join(directory, 'bin') + mkdirSync(bin) + symlinkSync(modulePath, join(bin, 'sim')) + const globalDirectory = + packageManager === 'bun' + ? bin + : packageManager === 'yarn' + ? directory + : join(directory, 'node_modules') + const run = vi + .fn() + .mockResolvedValueOnce(globalDirectory) + .mockResolvedValueOnce( + packageManager === 'yarn' + ? JSON.stringify({ type: 'inspect', data: '2.1.5' }) + + '\n' + + JSON.stringify({ type: 'finished', data: 1 }) + : JSON.stringify('2.1.5') + ) + .mockImplementationOnce(() => { + writeVersion('2.1.5') + return Promise.resolve('') + }) + + await installUpdate({ ...options(), packageManager, run }) + + const expectedArgs = + packageManager === 'npm' + ? ['install', '-g', 'sim@2.1.5'] + : packageManager === 'yarn' + ? ['global', 'add', 'sim@2.1.5'] + : ['add', '-g', 'sim@2.1.5'] + expect(run).toHaveBeenLastCalledWith(packageManager, expectedArgs, { + env: { SIM_NO_UPDATE_CHECK: '1' }, + capture: false, + }) + expect(output.join('')).toContain('Updated Sim 2.1.2 → 2.1.5') + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + } + ) + + it.each([ + ['2.1.2', 'latest'], + ['2.1.2-preview.123.1', 'staging'], + ['2.1.2-dev.123.1', 'dev'], + ])('preserves the release channel for %s', async (currentVersion, tag) => { + writeVersion(currentVersion) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify(currentVersion)) + await installUpdate({ ...options(), currentVersion, run }) + expect(run.mock.calls[1][1]).toEqual(['view', `sim@${tag}`, 'version', '--json']) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('does not pass the Sim API key to the package manager or change the parent environment', async () => { + const env = { SIM_API_KEY: 'private', npm_config_registry: 'https://registry.example' } + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.2')) + await installUpdate({ ...options(), env, run }) + expect(run.mock.calls[1][2].env).toEqual({ + npm_config_registry: 'https://registry.example', + SIM_NO_UPDATE_CHECK: '1', + }) + expect(env.SIM_API_KEY).toBe('private') + }) + + it('reports an already current installation', async () => { + await installUpdate({ + ...options(), + run: vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.2')), + }) + expect(output.join('')).toContain('already up to date') + }) + + it.each([ + ['2.1.5', '2.1.2'], + ['2.1.10', '2.1.9'], + ['3.0.0', '2.99.99'], + ['2.1.5-preview.10.1', '2.1.5-preview.9.9'], + ['2.1.5-dev.10.2', '2.1.5-dev.10.1'], + ])('refuses to downgrade %s to %s before installation', async (currentVersion, candidate) => { + writeVersion(currentVersion) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify(candidate)) + await expect(installUpdate({ ...options(), currentVersion, run })).rejects.toThrow( + 'Refusing to downgrade' + ) + expect(run).toHaveBeenCalledTimes(2) + expect(readFileSync(join(packageRoot, 'package.json'), 'utf8')).toContain(currentVersion) + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + }) + + it.each([ + ['2.1.9', '2.1.10'], + ['2.1.5-preview.9.9', '2.1.5-preview.10.1'], + ['2.1.5-dev.10.9', '2.1.5-dev.10.10'], + ])( + 'compares numeric release components when updating %s to %s', + async (currentVersion, candidate) => { + writeVersion(currentVersion) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify(candidate)) + .mockImplementationOnce(async () => { + writeVersion(candidate) + return '' + }) + await installUpdate({ ...options(), currentVersion, run }) + expect(run.mock.calls[2][1]).toEqual(['install', '-g', `sim@${candidate}`]) + } + ) + + it('does not reinstall versions that differ only in build metadata', async () => { + writeVersion('2.1.2+local') + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.2+registry')) + await installUpdate({ ...options(), currentVersion: '2.1.2+local', run }) + expect(run).toHaveBeenCalledTimes(2) + expect(output.join('')).toContain('already up to date') + }) + + it.each([ + '"2.1.5-dev.1.1"', + '"invalid"', + '"2.1.5; echo unsafe"', + '"2.1.05"', + '{', + '["2.1.5"]', + 'null', + ])( + 'rejects invalid or wrong-channel registry metadata without installing: %s', + async (metadata) => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(metadata) + await expect(installUpdate({ ...options(), run })).rejects.toBeInstanceOf(CliUpdateError) + expect(run).toHaveBeenCalledTimes(2) + expect(output).toEqual([]) + } + ) + + it('rejects ambiguous Yarn version events', async () => { + const event = JSON.stringify({ type: 'inspect', data: '2.1.5' }) + const run = vi.fn().mockResolvedValueOnce(directory).mockResolvedValueOnce(`${event}\n${event}`) + await expect(installUpdate({ ...options(), packageManager: 'yarn', run })).rejects.toThrow( + 'single Sim release' + ) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('refuses to install if the current installation changed while locating it', async () => { + const run = vi.fn().mockImplementationOnce(async () => { + writeVersion('2.1.6') + return join(directory, 'node_modules') + }) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('installation changed') + expect(run).toHaveBeenCalledTimes(1) + }) + + it('normalizes a missing installation entry', async () => { + const run = vi.fn().mockResolvedValue(join(directory, 'missing')) + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('Cannot access the Sim installation'), + }) + }) + + it.each(['missing', 'malformed', 'directory'])( + 'normalizes a %s installed manifest', + async (failure) => { + const manifestPath = join(packageRoot, 'package.json') + rmSync(manifestPath) + if (failure === 'malformed') writeFileSync(manifestPath, '{') + if (failure === 'directory') mkdirSync(manifestPath) + const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('Cannot read the installed Sim manifest'), + }) + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + } + ) + + it('verifies the new pnpm symlink instead of reading the old version directory', async () => { + const globalRoot = join(directory, 'global/node_modules') + mkdirSync(globalRoot, { recursive: true }) + const link = join(globalRoot, 'sim') + symlinkSync(packageRoot, link) + const nextRoot = join(directory, 'next/node_modules/sim') + mkdirSync(join(nextRoot, 'dist'), { recursive: true }) + writeFileSync(join(nextRoot, 'dist/index.js'), '') + writeVersion('2.1.5', nextRoot) + const run = vi + .fn() + .mockResolvedValueOnce(globalRoot) + .mockResolvedValueOnce(JSON.stringify('2.1.5')) + .mockImplementationOnce(() => { + unlinkSync(link) + symlinkSync(nextRoot, link) + return Promise.resolve('') + }) + await installUpdate({ ...options(), packageManager: 'pnpm', run }) + expect(output.join('')).toContain('Updated Sim 2.1.2 → 2.1.5') + }) + + it('refuses to update another installation even when both versions match', async () => { + const other = join(directory, 'other/node_modules') + mkdirSync(join(other, 'sim/dist'), { recursive: true }) + writeFileSync(join(other, 'sim/dist/index.js'), '') + const run = vi.fn().mockResolvedValue(other) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('different Sim installation') + expect(run).toHaveBeenCalledTimes(1) + }) + + it.each(['relative/path', '/path\nextra output', ''])( + 'refuses an invalid package-manager directory: %s', + async (path) => { + const run = vi.fn().mockResolvedValue(path) + await expect(installUpdate({ ...options(), run })).rejects.toThrow( + 'valid global installation path' + ) + expect(run).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + 'checkout/src/index.ts', + '_npx/cache/node_modules/sim/dist/index.js', + 'bunx-123/node_modules/sim/dist/index.js', + ])('refuses a checkout or temporary installation: %s', async (path) => { + const entry = join(directory, path) + mkdirSync(dirname(entry), { recursive: true }) + writeFileSync(entry, '') + const run = vi.fn() + await expect(installUpdate({ ...options(), modulePath: entry, run })).rejects.toThrow( + 'global installation' + ) + expect(run).not.toHaveBeenCalled() + }) + + it('refuses npm exec without spawning a package manager', async () => { + const run = vi.fn() + await expect( + installUpdate({ ...options(), env: { npm_command: 'exec' }, run }) + ).rejects.toThrow('global installation') + expect(run).not.toHaveBeenCalled() + }) + + it('fails on an unknown prerelease channel', async () => { + const run = vi.fn() + await expect( + installUpdate({ ...options(), currentVersion: '2.1.2-beta.1', run }) + ).rejects.toThrow('release channel') + expect(run).not.toHaveBeenCalled() + }) + + it('rejects executable text in the version before running anything', async () => { + const run = vi.fn() + await expect( + installUpdate({ ...options(), currentVersion: '2.1.5; echo unsafe', run }) + ).rejects.toThrow('release channel') + expect(run).not.toHaveBeenCalled() + }) + + it('propagates installer failure, releases the lock, and never reports success', async () => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.5')) + .mockRejectedValueOnce(new Error('permission denied')) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('permission denied') + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + expect(output.join('')).not.toContain('Updated Sim') + }) + + it.each(['registry', 'installer'])( + 'preserves the original %s failure when releasing the lock also fails', + async (phase) => { + const failure = new CliUpdateError(`${phase} permission denied`) + const run = vi.fn().mockResolvedValueOnce(join(directory, 'node_modules')) + if (phase === 'installer') run.mockResolvedValueOnce(JSON.stringify('2.1.5')) + run.mockImplementationOnce(async () => { + writeFileSync(join(`${packageRoot}.lock`, 'obstruction'), '') + throw failure + }) + + await expect(installUpdate({ ...options(), run })).rejects.toBe(failure) + expect(output.join('')).toContain('Cannot release the Sim update lock') + expect(output.join('')).not.toContain('Updated Sim') + } + ) + + it('fails with a CLI error when only releasing the lock fails', async () => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockImplementationOnce(async () => { + writeFileSync(join(`${packageRoot}.lock`, 'obstruction'), '') + return JSON.stringify('2.1.2') + }) + + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('Cannot release the Sim update lock'), + cause: expect.objectContaining({ code: 'ENOTEMPTY' }), + }) + }) + + it.each(['invalid', '2.1.5-dev.1.1'])( + 'rejects an invalid or wrong-channel installed version: %s', + async (version) => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.5')) + .mockImplementationOnce(() => { + writeVersion(version) + return Promise.resolve('') + }) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('expected Sim version') + expect(output.join('')).not.toContain('Updated Sim') + } + ) + + it('refuses concurrent updates before a second installer starts', async () => { + mkdirSync(`${packageRoot}.lock`) + const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('already being held'), + }) + expect(run).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sim-cli/src/update/install.ts b/packages/sim-cli/src/update/install.ts new file mode 100644 index 00000000000..fa750e8950a --- /dev/null +++ b/packages/sim-cli/src/update/install.ts @@ -0,0 +1,284 @@ +import { spawn } from 'node:child_process' +import { readFileSync, realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { getErrorMessage } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' +import { lock } from 'proper-lockfile' +import { upgradeCommand } from '#sim-cli/update/check' +import { CLI_VERSION } from '#sim-cli/version' + +export class CliUpdateError extends Error {} + +const PACKAGE_MANAGERS = ['npm', 'pnpm', 'bun', 'yarn'] as const +export type PackageManager = (typeof PACKAGE_MANAGERS)[number] + +interface PackageManagerOptions { + env: NodeJS.ProcessEnv + capture: boolean +} + +type RunPackageManager = ( + manager: PackageManager, + args: string[], + options: PackageManagerOptions +) => Promise + +interface InstallUpdateOptions { + modulePath?: string + env?: NodeJS.ProcessEnv + currentVersion?: string + packageManager?: PackageManager + run?: RunPackageManager + write?: (message: string) => void +} + +/** Runs only fixed package-manager commands; installer output belongs on stderr. */ +const runPackageManager: RunPackageManager = (manager, args, { env, capture }) => + new Promise((resolve, reject) => { + const child = spawn(manager, args, { + cwd: homedir(), + env, + /** Windows package managers ship .cmd launchers. Arguments contain no user input. */ + shell: process.platform === 'win32', + stdio: ['ignore', capture ? 'pipe' : process.stderr, process.stderr], + timeout: capture ? 10_000 : 5 * 60_000, + killSignal: 'SIGKILL', + windowsHide: true, + }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + if (Buffer.byteLength(output) > 64 * 1024) { + child.kill('SIGKILL') + reject(new CliUpdateError(`${manager} returned too much output while checking Sim.`)) + } + }) + child.once('error', (error) => { + reject(new CliUpdateError(`Could not run ${manager}: ${getErrorMessage(error)}`)) + }) + child.once('close', (code, signal) => { + if (code !== 0) { + reject( + new CliUpdateError( + `${manager} ${args.join(' ')} failed (${signal ?? `exit ${code}`}). Resolve the package-manager error and run sim update again.` + ) + ) + return + } + resolve(output.trim()) + }) + }) + +interface ReleaseVersion { + channel: 'latest' | 'staging' | 'dev' + precedence: bigint[] +} + +/** Parses the release formats published by CI; build metadata has no precedence. */ +function parseReleaseVersion(version: string): ReleaseVersion { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(preview|dev)\.(0|[1-9]\d*)\.(0|[1-9]\d*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + version + ) + if (version.length > 256 || !match) { + throw new CliUpdateError(`Cannot determine the release channel for Sim ${version}.`) + } + const precedence = [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])] + if (match[4]) precedence.push(BigInt(match[5]), BigInt(match[6])) + return { + channel: match[4] === 'preview' ? 'staging' : match[4] === 'dev' ? 'dev' : 'latest', + precedence, + } +} + +/** Both versions have already been checked to belong to the same release channel. */ +function compareReleases(candidate: ReleaseVersion, current: ReleaseVersion): number { + for (const [index, component] of candidate.precedence.entries()) { + if (component !== current.precedence[index]) + return component > current.precedence[index] ? 1 : -1 + } + return 0 +} + +function resolveInstallationPath(path: string): string { + try { + return realpathSync(path) + } catch (cause) { + throw new CliUpdateError(`Cannot access the Sim installation: ${getErrorMessage(cause)}`, { + cause, + }) + } +} + +function readInstalledVersion(entrypoint: string): string { + const manifestPath = join(dirname(dirname(resolveInstallationPath(entrypoint))), 'package.json') + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (cause) { + throw new CliUpdateError(`Cannot read the installed Sim manifest: ${getErrorMessage(cause)}`, { + cause, + }) + } + if ( + typeof manifest !== 'object' || + manifest === null || + !('name' in manifest) || + manifest.name !== 'sim' || + !('version' in manifest) || + typeof manifest.version !== 'string' + ) { + throw new CliUpdateError( + 'The installed Sim manifest must name the sim package and its version.' + ) + } + return manifest.version +} + +/** Yarn Classic wraps the selected field in an inspect event among JSON status lines. */ +function parseRegistryVersion(output: string, manager: PackageManager): string { + let version: unknown + try { + if (manager === 'yarn') { + const events: unknown[] = output + .split(/\r?\n/) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + const inspections = events.filter( + (event): event is { type: 'inspect'; data: unknown } => + typeof event === 'object' && + event !== null && + 'type' in event && + event.type === 'inspect' && + 'data' in event + ) + if (inspections.length === 1) version = inspections[0].data + } else { + version = JSON.parse(output) + } + } catch (cause) { + throw new CliUpdateError(`${manager} returned invalid registry JSON.`, { cause }) + } + if (typeof version !== 'string') { + throw new CliUpdateError(`${manager} did not resolve a single Sim release version.`) + } + return version +} + +/** Updates the verified global installation and reports the version actually installed. */ +export async function installUpdate(options: InstallUpdateOptions = {}): Promise { + const modulePath = resolveInstallationPath(options.modulePath ?? fileURLToPath(import.meta.url)) + const env = omit(options.env ?? process.env, ['SIM_API_KEY']) + const normalized = modulePath.replaceAll('\\', '/').toLowerCase() + if ( + env.npm_command === 'exec' || + normalized.includes('/_npx/') || + normalized.includes('/bunx-') || + !normalized.endsWith('/node_modules/sim/dist/index.js') + ) { + throw new CliUpdateError( + 'sim update requires a global installation. Update project dependencies with their package manager, or use sim@latest with your package runner.' + ) + } + + const manager = options.packageManager ?? upgradeCommand(modulePath, env).split(' ')[0] + if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) { + throw new CliUpdateError('Cannot determine which package manager installed Sim.') + } + const packageManager = manager as PackageManager + const run = options.run ?? runPackageManager + const currentVersion = options.currentVersion ?? CLI_VERSION + const current = parseReleaseVersion(currentVersion) + const target = current.channel + const write = options.write ?? ((message: string) => void process.stderr.write(message)) + env.SIM_NO_UPDATE_CHECK = '1' + + const locateArgs = + packageManager === 'bun' + ? ['pm', 'bin', '-g'] + : packageManager === 'yarn' + ? ['global', 'dir', '--silent'] + : ['root', '-g'] + const directory = await run(packageManager, locateArgs, { env, capture: true }) + if (!isAbsolute(directory) || /[\r\n]/.test(directory)) { + throw new CliUpdateError(`${packageManager} did not return a valid global installation path.`) + } + const installedEntry = + packageManager === 'bun' + ? join(directory, 'sim') + : join(directory, ...(packageManager === 'yarn' ? ['node_modules'] : []), 'sim/dist/index.js') + if (resolveInstallationPath(installedEntry) !== modulePath) { + throw new CliUpdateError( + `${packageManager} would update a different Sim installation. Use the package manager and global configuration that installed this copy, or select --package-manager.` + ) + } + + const release = await lock(dirname(dirname(modulePath)), { retries: 0, realpath: false }).catch( + (cause: unknown) => { + throw new CliUpdateError(`Cannot lock Sim for update: ${getErrorMessage(cause)}`, { cause }) + } + ) + let updateFailed = false + try { + if (readInstalledVersion(installedEntry) !== currentVersion) { + throw new CliUpdateError( + 'The Sim installation changed while starting the update. Run sim update again.' + ) + } + const version = parseRegistryVersion( + await run( + packageManager, + [ + packageManager === 'bun' || packageManager === 'yarn' ? 'info' : 'view', + `sim@${target}`, + 'version', + '--json', + ], + { env, capture: true } + ), + packageManager + ) + const candidate = parseReleaseVersion(version) + if (candidate.channel !== target) { + throw new CliUpdateError('The registry resolved Sim to a different release channel.') + } + const comparison = compareReleases(candidate, current) + if (comparison < 0) { + throw new CliUpdateError( + `Refusing to downgrade Sim ${currentVersion} to ${version}. Check your package-manager registry settings.` + ) + } + if (comparison === 0) { + write(`Sim ${currentVersion} is already up to date.\n`) + return + } + write(`Updating Sim ${currentVersion} with ${packageManager} (sim@${version})…\n`) + const args = + packageManager === 'npm' + ? ['install', '-g', `sim@${version}`] + : packageManager === 'yarn' + ? ['global', 'add', `sim@${version}`] + : ['add', '-g', `sim@${version}`] + await run(packageManager, args, { env, capture: false }) + if (readInstalledVersion(installedEntry) !== version) { + throw new CliUpdateError('The package manager did not install the expected Sim version.') + } + write( + `Updated Sim ${currentVersion} → ${version}. The next invocation will use the new version.\n` + ) + } catch (error) { + updateFailed = true + throw error + } finally { + await release().catch((cause: unknown) => { + const message = `Cannot release the Sim update lock: ${getErrorMessage(cause)}` + if (updateFailed) { + write(`${message}\n`) + } else { + throw new CliUpdateError(message, { cause }) + } + }) + } +} diff --git a/packages/testing/src/mocks/hybrid-auth.mock.ts b/packages/testing/src/mocks/hybrid-auth.mock.ts index e9739496172..771edcea148 100644 --- a/packages/testing/src/mocks/hybrid-auth.mock.ts +++ b/packages/testing/src/mocks/hybrid-auth.mock.ts @@ -39,7 +39,6 @@ export const hybridAuthMockFns = { mockCheckHybridAuth: vi.fn(defaultCheckSessionOrInternalAuth), mockCheckSessionOrInternalAuth: vi.fn(defaultCheckSessionOrInternalAuth), mockCheckInternalAuth: vi.fn(), - mockHasExternalApiCredentials: vi.fn(() => false), } /** @@ -71,5 +70,4 @@ export const hybridAuthMock = { checkHybridAuth: hybridAuthMockFns.mockCheckHybridAuth, checkSessionOrInternalAuth: hybridAuthMockFns.mockCheckSessionOrInternalAuth, checkInternalAuth: hybridAuthMockFns.mockCheckInternalAuth, - hasExternalApiCredentials: hybridAuthMockFns.mockHasExternalApiCredentials, } diff --git a/packages/testing/src/mocks/logger.mock.ts b/packages/testing/src/mocks/logger.mock.ts index f7330cb2bc8..dc4d10628c9 100644 --- a/packages/testing/src/mocks/logger.mock.ts +++ b/packages/testing/src/mocks/logger.mock.ts @@ -40,6 +40,7 @@ export const loggerMock = { runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), setRequestTraceId: vi.fn(), + setRequestAuth: vi.fn(), } /** diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 5f7427e14b9..8d951f74c6c 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1629,6 +1629,15 @@ export const schemaMock = { approved: 'organizationSearchIntegration.approved', updatedAt: 'organizationSearchIntegration.updatedAt', }, + organizationSearchInvocation: { + id: 'organizationSearchInvocation.id', + organizationId: 'organizationSearchInvocation.organizationId', + userId: 'organizationSearchInvocation.userId', + surface: 'organizationSearchInvocation.surface', + sourceTypes: 'organizationSearchInvocation.sourceTypes', + resultCount: 'organizationSearchInvocation.resultCount', + createdAt: 'organizationSearchInvocation.createdAt', + }, knowledgeConnector: { id: 'knowledgeConnector.id', knowledgeBaseId: 'knowledgeConnector.knowledgeBaseId', diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index 8050171dd7b..0a1f7955627 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -1,5 +1,6 @@ +import { readFileSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SimStudioClient, SimStudioError } from './index' +import { SDK_VERSION, SimStudioClient, SimStudioError } from './index' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) @@ -788,3 +789,31 @@ describe('SimStudioError', () => { expect(error.status).toBe(400) }) }) + +describe('client identity', () => { + it('keeps SDK_VERSION in step with package.json', () => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + expect(SDK_VERSION).toBe(manifest.version) + }) + + it('identifies the SDK on every request', async () => { + const client = new SimStudioClient({ apiKey: 'test-api-key', baseUrl: 'https://test.sim.ai' }) + vi.mocked(mockFetch).mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ data: { isDeployed: true } }), + } as any) + + await client.getWorkflowStatus('workflow-id') + + const headers = vi.mocked(mockFetch).mock.calls[0][1]?.headers as Record + expect(headers['X-Sim-Client-Info']).toBe( + `sdk-js/${SDK_VERSION}; node/${process.versions.node}` + ) + expect(headers['User-Agent']).toBe( + `simstudio-ts-sdk/${SDK_VERSION} node/${process.versions.node}` + ) + expect(headers['X-API-Key']).toBe('test-api-key') + }) +}) diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index 22bf0fed732..6982f9acd85 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -3,6 +3,29 @@ export interface SimStudioConfig { baseUrl?: string } +/** + * The published package version. Kept in step with `package.json` by a test + * rather than read at runtime, so the SDK stays usable where there is no file + * system to read it from. + */ +export const SDK_VERSION = '0.2.0' + +/** + * Identifies this SDK to the API on every request, the way every official Sim + * client does, so a server log line or analytics event can say which client + * made the call. `X-Sim-Client-Info` is the header the server reads and works + * everywhere; `User-Agent` is added only where the runtime lets a script set it. + */ +const CLIENT_HEADERS: Readonly> = (() => { + const node = typeof process !== 'undefined' ? process.versions?.node : undefined + const runtime = node ? `; node/${node}` : '' + const headers: Record = { + 'X-Sim-Client-Info': `sdk-js/${SDK_VERSION}${runtime}`, + } + if (node) headers['User-Agent'] = `simstudio-ts-sdk/${SDK_VERSION} node/${node}` + return headers +})() + export interface LargeValueRef { __simLargeValueRef: true version: 1 @@ -196,6 +219,11 @@ export class SimStudioClient { this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai') } + /** The headers every request carries: the credential and the client's identity. */ + private requestHeaders(): Record { + return { ...CLIENT_HEADERS, 'X-API-Key': this.apiKey } + } + /** * Convert File objects in input to API format (base64) * Recursively processes nested objects and arrays @@ -284,8 +312,8 @@ export class SimStudioClient { }) const headers: Record = { + ...this.requestHeaders(), 'Content-Type': 'application/json', - 'X-API-Key': this.apiKey, } let workflowInput: any = {} @@ -418,9 +446,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) if (!response.ok) { @@ -500,9 +526,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) this.updateRateLimitInfo(response) @@ -548,9 +572,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) this.updateRateLimitInfo(response) @@ -676,9 +698,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) this.updateRateLimitInfo(response) diff --git a/packages/utils/package.json b/packages/utils/package.json index 8d930f16c9e..97ce4f54663 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -18,6 +18,10 @@ "types": "./src/random.ts", "default": "./src/random.ts" }, + "./client-info": { + "types": "./src/client-info.ts", + "default": "./src/client-info.ts" + }, "./color": { "types": "./src/color.ts", "default": "./src/color.ts" diff --git a/packages/utils/src/client-info.test.ts b/packages/utils/src/client-info.test.ts new file mode 100644 index 00000000000..516d2b778e5 --- /dev/null +++ b/packages/utils/src/client-info.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + CLIENT_INFO_HEADER, + formatClientInfo, + parseClientInfo, + resolveClientInfo, +} from './client-info' + +function headers(entries: Record) { + const map = new Map(Object.entries(entries).map(([key, value]) => [key.toLowerCase(), value])) + return { get: (name: string) => map.get(name.toLowerCase()) ?? null } +} + +describe('formatClientInfo', () => { + it('renders every field as a product token in a fixed order', () => { + expect( + formatClientInfo({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'node', version: '22.14.0' }, + os: 'darwin', + arch: 'arm64', + agent: 'claude-code', + }) + ).toBe('cli/2.1.2; node/22.14.0; os/darwin; arch/arm64; agent/claude-code') + }) + + it('renders an unversioned surface as a bare token', () => { + expect(formatClientInfo({ surface: 'web' })).toBe('web') + }) + + it('refuses a value that is not an RFC 9110 token', () => { + expect(() => formatClientInfo({ surface: 'cli', version: '2.1.2 beta' })).toThrow( + /not a valid token/ + ) + }) +}) + +describe('parseClientInfo', () => { + it('round-trips a formatted value', () => { + const info = { + surface: 'desktop' as const, + version: '1.4.2', + runtime: { name: 'electron', version: '43.5.0' }, + os: 'darwin', + arch: 'arm64', + } + expect(parseClientInfo(formatClientInfo(info))).toEqual(info) + }) + + it('accepts trailing tokens in any order', () => { + expect(parseClientInfo('cli/2.1.2; agent/codex; arch/x64; node/20.0.0; os/linux')).toEqual({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'node', version: '20.0.0' }, + os: 'linux', + arch: 'x64', + agent: 'codex', + }) + }) + + it('returns undefined for an unknown surface', () => { + expect(parseClientInfo('curl/8.0.0')).toBeUndefined() + }) + + it('returns undefined for an empty, missing, or oversized value', () => { + expect(parseClientInfo('')).toBeUndefined() + expect(parseClientInfo(null)).toBeUndefined() + expect(parseClientInfo(undefined)).toBeUndefined() + expect(parseClientInfo(`cli/1.0.0; ${'x'.repeat(300)}/1`)).toBeUndefined() + }) + + it('skips malformed and unknown trailing tokens instead of failing', () => { + expect(parseClientInfo('cli/2.1.2; ; not a token; future/thing; os/darwin')).toEqual({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'future', version: 'thing' }, + os: 'darwin', + }) + }) + + it('carries any well-formed agent token through', () => { + expect(parseClientInfo('cli/2.1.2; agent/some-new-agent')?.agent).toBe('some-new-agent') + }) + + it('keeps the first of a repeated token', () => { + expect(parseClientInfo('cli/2.1.2; os/darwin; os/linux')?.os).toBe('darwin') + }) +}) + +describe('resolveClientInfo', () => { + it('prefers a declared header over every other signal', () => { + const resolved = resolveClientInfo( + headers({ + [CLIENT_INFO_HEADER]: 'cli/2.1.2; node/22.0.0', + 'user-agent': 'Mozilla/5.0', + 'sec-fetch-mode': 'cors', + }), + { hasExternalCredentials: true } + ) + expect(resolved).toEqual({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'node', version: '22.0.0' }, + source: 'header', + }) + }) + + it('recognises the user agent of CLI releases that predate the header', () => { + const resolved = resolveClientInfo( + headers({ 'user-agent': 'sim-cli/2.0.9 node/22.14.0 (darwin; arm64)' }), + { hasExternalCredentials: true } + ) + expect(resolved).toEqual({ surface: 'cli', version: '2.0.9', source: 'user_agent' }) + }) + + it('infers the web app from fetch metadata on an uncredentialed browser request', () => { + const resolved = resolveClientInfo(headers({ 'sec-fetch-mode': 'cors' }), { + hasExternalCredentials: false, + }) + expect(resolved).toEqual({ surface: 'web', source: 'fetch_metadata' }) + }) + + it('leaves a credentialed browser request unattributed', () => { + expect( + resolveClientInfo(headers({ 'sec-fetch-mode': 'cors' }), { hasExternalCredentials: true }) + ).toBeUndefined() + }) + + it('leaves a bare request unattributed', () => { + expect( + resolveClientInfo(headers({ 'user-agent': 'curl/8.0.0' }), { hasExternalCredentials: true }) + ).toBeUndefined() + }) +}) diff --git a/packages/utils/src/client-info.ts b/packages/utils/src/client-info.ts new file mode 100644 index 00000000000..09537a4fafa --- /dev/null +++ b/packages/utils/src/client-info.ts @@ -0,0 +1,204 @@ +/** + * Client attribution: which official Sim client sent a request. + * + * Every first-party client declares itself with one header, `X-Sim-Client-Info`, + * whose value is a list of `name/version` product tokens in the `User-Agent` + * grammar of RFC 9110 §10.1.5, separated by `;` the way Supabase's + * `X-Client-Info` and Google's `x-goog-api-client` are. A custom header rather + * than `User-Agent` alone because a browser and an Electron renderer cannot set + * `User-Agent`, so a single header every client can send is the only channel + * that gives the server one place to look. + * + * ```text + * X-Sim-Client-Info: cli/2.1.2; node/22.14.0; os/darwin; arch/arm64; agent/claude-code + * X-Sim-Client-Info: desktop/1.4.2; electron/43.5.0; os/darwin; arch/arm64 + * X-Sim-Client-Info: web + * ``` + * + * The first token names the surface and, optionally, its version. The tokens + * after it are keyed by name: `os`, `arch` and `agent` are reserved, and the + * first unreserved one is the runtime. Order among the trailing tokens does + * not matter, and unknown tokens are ignored so a newer client can add one + * without breaking an older server. + * + * Attribution is analytics and log metadata only. It is caller-controlled and + * is never an authorization input. + */ + +export const CLIENT_INFO_HEADER = 'x-sim-client-info' + +/** The official Sim clients, as they name themselves on the wire. */ +export const SIM_SURFACES = ['web', 'desktop', 'cli', 'sdk-js', 'sdk-python'] as const + +export type SimSurface = (typeof SIM_SURFACES)[number] + +export interface ClientInfo { + surface: SimSurface + /** The client's own version, absent when the client is not versioned (the web app). */ + version?: string + /** The runtime the client executes in, such as `node`, `electron`, or `python`. */ + runtime?: { name: string; version: string } + /** Operating system and CPU architecture, in the names the runtime reports. */ + os?: string + arch?: string + /** + * The AI coding agent driving the client (`claude-code`, `codex`, `cursor`, + * …), when one could be detected. Detection lives with the CLI, the only + * client that runs inside an agent's shell; the server carries the value + * through. An open token rather than a closed list because new agents appear + * faster than a server can be redeployed to know their names. + */ + agent?: string +} + +/** How the server established a request's client. */ +export type ClientInfoSource = 'header' | 'user_agent' | 'fetch_metadata' + +export interface ResolvedClientInfo extends ClientInfo { + source: ClientInfoSource +} + +/** Bounds a caller-controlled header before it is parsed or logged. */ +const MAX_HEADER_LENGTH = 256 + +/** RFC 9110 `token` characters minus the delimiters this header reserves. */ +const TOKEN_PATTERN = /^[A-Za-z0-9._+-]+$/ + +/** Trailing token names with a fixed meaning; anything else is the runtime. */ +const OS_KEY = 'os' +const ARCH_KEY = 'arch' +const AGENT_KEY = 'agent' + +/** The `User-Agent` older CLI releases sent before the header existed. */ +const LEGACY_CLI_USER_AGENT = /^sim-cli\/([A-Za-z0-9._+-]+)/ + +/** The header browsers attach to every request and non-browser clients never do. */ +const FETCH_METADATA_HEADER = 'sec-fetch-mode' + +const SURFACE_SET: ReadonlySet = new Set(SIM_SURFACES) + +function isSurface(value: string): value is SimSurface { + return SURFACE_SET.has(value) +} + +function isToken(value: string): boolean { + return TOKEN_PATTERN.test(value) +} + +function product(name: string, version?: string): string { + if (!isToken(name)) throw new Error(`Client info token name is not a valid token: ${name}`) + if (version === undefined) return name + if (!isToken(version)) + throw new Error(`Client info token version is not a valid token: ${version}`) + return `${name}/${version}` +} + +/** + * Renders the `X-Sim-Client-Info` value a client sends. + * + * Throws on a value that is not an RFC 9110 token, because every field comes + * from the client's own build or runtime constants and a bad one is a bug in + * the client, not input to tolerate. + */ +export function formatClientInfo(info: ClientInfo): string { + const tokens = [product(info.surface, info.version)] + if (info.runtime) tokens.push(product(info.runtime.name, info.runtime.version)) + if (info.os) tokens.push(product(OS_KEY, info.os)) + if (info.arch) tokens.push(product(ARCH_KEY, info.arch)) + if (info.agent) tokens.push(product(AGENT_KEY, info.agent)) + return tokens.join('; ') +} + +function splitProduct(token: string): { name: string; version?: string } | undefined { + const slash = token.indexOf('/') + const name = slash === -1 ? token : token.slice(0, slash) + const version = slash === -1 ? undefined : token.slice(slash + 1) + if (!isToken(name)) return undefined + if (version !== undefined && !isToken(version)) return undefined + return { name, version } +} + +/** + * Parses an `X-Sim-Client-Info` value. + * + * Returns `undefined` for anything that does not name a known surface, so an + * unrecognised or malformed header reads as "unattributed" rather than as a + * client that does not exist. Trailing tokens are tolerant: a malformed or + * unknown one is skipped, never fatal, because a client may legitimately be + * newer than the server reading it. + */ +export function parseClientInfo(value: string | null | undefined): ClientInfo | undefined { + if (!value || value.length > MAX_HEADER_LENGTH) return undefined + + const tokens = value.split(';').map((token) => token.trim()) + const first = tokens[0] ? splitProduct(tokens[0]) : undefined + if (!first || !isSurface(first.name)) return undefined + + const info: ClientInfo = { surface: first.name } + if (first.version !== undefined) info.version = first.version + + for (const token of tokens.slice(1)) { + if (token === '') continue + const parsed = splitProduct(token) + if (!parsed || parsed.version === undefined) continue + + if (parsed.name === OS_KEY) { + info.os ??= parsed.version + } else if (parsed.name === ARCH_KEY) { + info.arch ??= parsed.version + } else if (parsed.name === AGENT_KEY) { + info.agent ??= parsed.version + } else { + info.runtime ??= { name: parsed.name, version: parsed.version } + } + } + + return info +} + +/** The one method every request abstraction exposes, so a minimal test double qualifies. */ +export interface HeaderReader { + get(name: string): string | null +} + +export interface ResolveClientInfoOptions { + /** + * Whether the request carries external API credentials (an API key or a + * bearer token). Decided by the caller, which owns the credential header + * names, so this module never has to know them. + */ + hasExternalCredentials: boolean +} + +/** + * Establishes which client sent a request, from the strongest signal available. + * + * 1. `X-Sim-Client-Info`, when a client declared itself. + * 2. The `User-Agent` of CLI releases that predate the header. + * 3. Fetch Metadata. Browsers stamp `Sec-Fetch-*` on every request and nothing + * else does, so a browser request that carries no external credentials can + * only have come from a page Sim served — the web app, or a public surface + * such as a shared chat. A browser request that does carry an API key is a + * third-party integration and is deliberately left unattributed. The web app + * declares itself on its contract-bound calls; this covers the raw-`fetch` + * exceptions and stale bundles that do not. + * + * Returns `undefined` when none of these apply: direct API traffic from an + * unofficial client, or a server-to-server call. + */ +export function resolveClientInfo( + headers: HeaderReader, + options: ResolveClientInfoOptions +): ResolvedClientInfo | undefined { + const declared = parseClientInfo(headers.get(CLIENT_INFO_HEADER)) + if (declared) return { ...declared, source: 'header' } + + const legacyCli = LEGACY_CLI_USER_AGENT.exec(headers.get('user-agent') ?? '') + if (legacyCli) return { surface: 'cli', version: legacyCli[1], source: 'user_agent' } + + if (headers.get(FETCH_METADATA_HEADER) !== null && !options.hasExternalCredentials) { + return { surface: 'web', source: 'fetch_metadata' } + } + + return undefined +} diff --git a/renovate.json b/renovate.json deleted file mode 100644 index eb451c8ebc8..00000000000 --- a/renovate.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended"], - "timezone": "America/Los_Angeles", - "schedule": ["before 6am on Monday"], - "minimumReleaseAge": "7 days", - "osvVulnerabilityAlerts": true, - "vulnerabilityAlerts": { - "enabled": true, - "addLabels": ["security"], - "schedule": ["at any time"], - "minimumReleaseAge": null - }, - "dependencyDashboard": true, - "labels": ["dependencies"], - "prConcurrentLimit": 5, - "prHourlyLimit": 2, - "rangeStrategy": "replace", - "lockFileMaintenance": { - "enabled": true, - "schedule": ["before 6am on Monday"] - }, - "packageRules": [ - { - "description": "Group compatible non-major npm updates that are not in a coupled family", - "matchManagers": ["bun"], - "matchUpdateTypes": ["minor", "patch"], - "groupName": "npm non-major dependencies" - }, - { - "description": "Keep the generated AWS and Smithy release families aligned", - "matchPackageNames": ["@aws-sdk/**", "@smithy/**"], - "groupName": "AWS SDK" - }, - { - "description": "Keep OpenTelemetry API, SDK, and exporters on compatible release lines", - "matchPackageNames": ["@opentelemetry/**"], - "groupName": "OpenTelemetry" - }, - { - "description": "Keep React, Next.js, and their platform packages aligned", - "matchPackageNames": [ - "react", - "react-dom", - "next", - "@next/**", - "@types/react", - "@types/react-dom" - ], - "groupName": "React and Next.js" - }, - { - "description": "Keep Radix primitives on one tested release family", - "matchPackageNames": ["@radix-ui/**"], - "groupName": "Radix UI" - }, - { - "description": "Keep TipTap extensions and ProseMirror bindings aligned", - "matchPackageNames": ["@tiptap/**"], - "groupName": "TipTap" - }, - { - "description": "Keep React Email packages and CLI aligned", - "matchPackageNames": ["react-email", "@react-email/**"], - "groupName": "React Email" - }, - { - "description": "Keep Trigger.dev runtime, build package, and CLI aligned", - "matchPackageNames": ["@trigger.dev/**", "trigger.dev"], - "groupName": "Trigger.dev" - }, - { - "description": "Group immutable GitHub Actions digest refreshes", - "matchManagers": ["github-actions"], - "matchUpdateTypes": ["digest"], - "groupName": "GitHub Actions digests", - "minimumReleaseAge": null - } - ] -} diff --git a/scripts/check-script-test-coverage.ts b/scripts/check-script-test-coverage.ts index 87dc148c458..e92dafdfe13 100644 --- a/scripts/check-script-test-coverage.ts +++ b/scripts/check-script-test-coverage.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun /** - * Asserts every `scripts/*.test.ts` file is collected by the root Vitest config. + * Asserts every `scripts/*.test.ts` file is collected by the scripts Vitest config. * * The root `test` script once chained a hand-maintained list of `test:*` entries, and a * hand-maintained list silently drifts from the files on disk: a test added without a matching * entry never runs, in CI or locally, and nothing reports it. `scripts/check-migrations-safety.test.ts` - * sat unreferenced and green for exactly that reason. The root `vitest.scripts.config.ts` now collects + * sat unreferenced and green for exactly that reason. `scripts/vitest.config.ts` now collects * the directory by glob, so drift can only come from a file the glob does not match (a test in a * subdirectory, a different suffix) or from the `test` script no longer chaining `test:scripts`. * This guard checks both by asking Vitest which files it would run. @@ -42,7 +42,7 @@ if (!reachableScripts('test').has('test:scripts')) { } const listed = Bun.spawnSync( - ['bunx', 'vitest', 'list', '--json', '--filesOnly', '--config', 'vitest.scripts.config.ts'], + ['bunx', 'vitest', 'list', '--json', '--filesOnly', '--config', 'scripts/vitest.config.ts'], { cwd: ROOT, } @@ -66,11 +66,11 @@ const orphaned = onDisk.filter((file) => !collected.has(file)) if (orphaned.length > 0) { console.error( `Script tests never run by \`bun run test\`:\n${orphaned.map((file) => ` - ${file}`).join('\n')}\n` + - 'Make sure the root `vitest.scripts.config.ts` include glob matches them.' + 'Make sure the `scripts/vitest.config.ts` include glob matches them.' ) process.exit(1) } console.log( - `Script test coverage passed: ${onDisk.length} script tests collected by the root Vitest config.` + `Script test coverage passed: ${onDisk.length} script tests collected by the scripts Vitest config.` ) diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 6cd86c50db8..aee73ae7ff3 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -46,6 +46,7 @@ export const GUIDE_PAGES = [ 'scripting', 'workflow-sync', 'troubleshooting', + 'usage-data', ] as const /** Generated page holding the global options and the commands that take no resource. */ diff --git a/scripts/sync-skills.ts b/scripts/sync-skills.ts index a472d883ea9..bf9914a11bf 100644 --- a/scripts/sync-skills.ts +++ b/scripts/sync-skills.ts @@ -1,25 +1,44 @@ /** - * Generate the per-tool skill projections from the canonical skill sources so - * every supported agent surface stays aligned. + * Generate the per-tool skill and rule projections from the canonical sources + * so every supported agent surface stays aligned. * - * Canonical source of truth: `.agents/skills//SKILL.md` + * Skills — canonical source of truth: `.agents/skills//SKILL.md` * frontmatter: `name`, `description`, optional `argument-hint`; then the body. * - * Projection (generated — do not hand-edit): + * Projection (generated — do not hand-edit): * - `.claude/skills/` — symlink to the complete canonical skill directory. * - * Cursor discovers `.agents/skills` directly, so it needs no projection. - * Deprecated `.claude/commands/.md` and `.cursor/commands/.md` - * projections are removed when syncing and treated as stale in check mode. + * Cursor discovers `.agents/skills` directly, so it needs no projection. + * Deprecated `.claude/commands/.md` and `.cursor/commands/.md` + * projections are removed when syncing and treated as stale in check mode. * - * Claude receives the complete skill directory so bundled resources remain - * available. + * Claude receives the complete skill directory so bundled resources remain + * available. + * + * Rules — canonical source of truth: `.claude/rules/.md` + * frontmatter: `description`, optional `paths` list; then the body. Claude + * reads these directly. + * + * Projection (generated — do not hand-edit): + * - `.cursor/rules/.mdc` — same body under Cursor's frontmatter: + * `description`, and `globs` from `paths` or `alwaysApply: true` when the + * rule has none. * * Usage: * bun run scripts/sync-skills.ts # write projections * bun run scripts/sync-skills.ts --check # fail (exit 1) if any projection is stale */ -import { lstat, mkdir, readdir, readFile, readlink, rm, rmdir, symlink } from 'node:fs/promises' +import { + lstat, + mkdir, + readdir, + readFile, + readlink, + rm, + rmdir, + symlink, + writeFile, +} from 'node:fs/promises' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' @@ -29,6 +48,8 @@ const CANONICAL_DIR = resolve(ROOT, '.agents/skills') const CLAUDE_SKILLS_DIR = resolve(ROOT, '.claude/skills') const LEGACY_CLAUDE_COMMANDS_DIR = resolve(ROOT, '.claude/commands') const LEGACY_CURSOR_COMMANDS_DIR = resolve(ROOT, '.cursor/commands') +const CANONICAL_RULES_DIR = resolve(ROOT, '.claude/rules') +const CURSOR_RULES_DIR = resolve(ROOT, '.cursor/rules') interface Skill { name: string @@ -73,6 +94,110 @@ async function loadCanonicalSkills(): Promise { return skills } +interface Rule { + name: string + description: string + paths: string[] + body: string +} + +/** Parse a canonical rule: `description`, optional `paths` list, then the body. */ +function parseRule(name: string, raw: string): Rule { + if (!raw.startsWith('---\n')) { + throw new Error(`${name}: rule must start with a '---' frontmatter block`) + } + const end = raw.indexOf('\n---\n', 4) + if (end === -1) throw new Error(`${name}: unterminated frontmatter block`) + const frontmatter = raw.slice(4, end).split('\n') + const body = raw.slice(end + '\n---\n'.length).replace(/^\n+/, '') + + let description: string | undefined + const paths: string[] = [] + let inPaths = false + for (const entry of frontmatter) { + if (entry.startsWith('description:')) { + description = entry.slice('description:'.length).trim() + inPaths = false + } else if (entry === 'paths:') { + inPaths = true + } else if (inPaths && /^\s+- /.test(entry)) { + paths.push( + entry + .replace(/^\s+- /, '') + .trim() + .replace(/^"(.*)"$/, '$1') + ) + } else { + inPaths = false + } + } + if (!description) throw new Error(`${name}: missing 'description' in frontmatter`) + if (!isPlainYamlScalar(description)) { + throw new Error( + `${name}: 'description' must be a plain YAML string (no leading quote or indicator, no ': ' or ' #', and not a bare boolean, null, number, or date) so both Claude and Cursor parse it` + ) + } + return { name, description, paths, body } +} + +/** + * Whether a value survives as an unquoted YAML plain scalar. The canonical rule + * frontmatter is written by hand and read by Claude as-is, so a description that + * needs quoting would silently break its `paths` there too; refuse it at the + * source instead of only quoting the projection. + */ +function isPlainYamlScalar(value: string): boolean { + if (value !== value.trim()) return false + if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(value)) return false + if (value.includes(': ') || value.includes(' #')) return false + if (value.endsWith(':')) return false + return !IMPLICITLY_TYPED_SCALAR.test(value) +} + +/** + * Plain scalars a YAML 1.1 or 1.2 reader resolves to something other than a + * string: booleans, null, integers (decimal, hex, octal, binary, underscored), + * floats (including .inf/.nan), and dates or timestamps. + */ +const IMPLICITLY_TYPED_SCALAR = new RegExp( + [ + '^(?:', + '~|null|true|false|yes|no|on|off|y|n', + '|[-+]?(?:0b[01_]+|0o?[0-7_]+|0x[0-9a-f_]+|[0-9][0-9_]*(?::[0-5]?[0-9])*)', + '|[-+]?(?:[0-9][0-9_]*)?\\.[0-9_]*(?:e[-+]?[0-9]+)?', + '|[-+]?[0-9][0-9_]*(?:\\.[0-9_]*)?e[-+]?[0-9]+', + '|[-+]?\\.(?:inf|nan)', + '|[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:[tT ].*)?', + ')$', + ].join(''), + 'i' +) + +async function loadCanonicalRules(): Promise { + const entries = await readdir(CANONICAL_RULES_DIR) + const rules: Rule[] = [] + for (const file of entries.filter((f) => f.endsWith('.md')).sort()) { + const raw = await readFile(resolve(CANONICAL_RULES_DIR, file), 'utf8') + rules.push(parseRule(file.slice(0, -'.md'.length), raw)) + } + return rules +} + +/** Render the Cursor `.mdc` projection of a canonical rule. */ +function renderCursorRule(rule: Rule): string { + const scope = rule.paths.length > 0 ? `globs: ${JSON.stringify(rule.paths)}` : 'alwaysApply: true' + return [ + '---', + `description: ${JSON.stringify(rule.description)}`, + scope, + '---', + '', + ``, + '', + rule.body, + ].join('\n') +} + /** Find generated Claude links whose canonical skill no longer exists. */ async function findOrphanedClaudeLinks(expectedPaths: ReadonlySet): Promise { const entries = await readdir(CLAUDE_SKILLS_DIR, { withFileTypes: true }).catch( @@ -150,6 +275,29 @@ async function main() { if (!check) await rm(path) } + const rules = await loadCanonicalRules() + const expectedCursorRules = new Set() + for (const rule of rules) { + const path = resolve(CURSOR_RULES_DIR, `${rule.name}.mdc`) + expectedCursorRules.add(path) + const rendered = renderCursorRule(rule) + const current = await readFile(path, 'utf8').catch(() => null) + if (current === rendered) continue + stale.push(path.replace(`${ROOT}/`, '')) + if (!check) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, rendered) + } + } + const cursorRuleFiles = await readdir(CURSOR_RULES_DIR).catch(() => [] as string[]) + for (const file of cursorRuleFiles) { + if (!file.endsWith('.mdc')) continue + const path = resolve(CURSOR_RULES_DIR, file) + if (expectedCursorRules.has(path)) continue + stale.push(`${path.replace(`${ROOT}/`, '')} (orphaned)`) + if (!check) await rm(path) + } + if (!check) { for (const path of [LEGACY_CLAUDE_COMMANDS_DIR, LEGACY_CURSOR_COMMANDS_DIR]) { await rmdir(path).catch((error: unknown) => { @@ -163,19 +311,21 @@ async function main() { if (check) { if (stale.length > 0) { console.error( - `✗ ${stale.length} skill projection(s) are stale — run \`bun run skills:sync\`:\n` + + `✗ ${stale.length} skill/rule projection(s) are stale — run \`bun run skills:sync\`:\n` + stale.map((p) => ` ${p}`).join('\n') ) process.exit(1) } - console.log(`✓ ${skills.length} canonical skills available to Claude and Cursor`) + console.log( + `✓ ${skills.length} canonical skills and ${rules.length} canonical rules available to Claude and Cursor` + ) return } console.log( stale.length === 0 - ? `✓ ${skills.length} skills already in sync — nothing to write` - : `✓ updated ${stale.length} skill surface(s) from ${skills.length} canonical skills` + ? `✓ ${skills.length} skills and ${rules.length} rules already in sync — nothing to write` + : `✓ updated ${stale.length} surface(s) from ${skills.length} canonical skills and ${rules.length} canonical rules` ) } diff --git a/scripts/vitest.config.ts b/scripts/vitest.config.ts new file mode 100644 index 00000000000..b80735697ea --- /dev/null +++ b/scripts/vitest.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +/** + * Repo-level scripts have their own suites. One invocation for all of them + * replaces eighteen sequential `vitest run ` processes, each of which + * paid its own startup. `scripts/openapi` keeps its own config and runs under + * `check:openapi`. + * + * Lives here rather than as a root `vitest.config.ts`: Vitest walks up from a + * package's directory looking for that name, so a root config would silently + * replace the defaults of every workspace package that has none of its own. + * The root is pinned so `bun run test:scripts` behaves the same from any cwd. + */ +export default defineConfig({ + test: { + root: fileURLToPath(new URL('..', import.meta.url)), + environment: 'node', + include: ['scripts/*.test.ts'], + }, +}) diff --git a/vitest.scripts.config.ts b/vitest.scripts.config.ts deleted file mode 100644 index 80d14604d7b..00000000000 --- a/vitest.scripts.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'vitest/config' - -/** - * Repo-level scripts have their own suites. One invocation for all of them - * replaces eleven sequential `vitest run ` processes, each of which paid - * its own startup. `scripts/openapi` keeps its own config and runs under - * `check:openapi`. - * - * Deliberately not named `vitest.config.ts`: Vitest walks up from a package's - * directory looking for that name, so a root config would silently replace - * the defaults of every workspace package that has none of its own. - */ -export default defineConfig({ - test: { - environment: 'node', - include: ['scripts/*.test.ts'], - }, -})