diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md index 35ef4596b24..8ac7f58a95c 100644 --- a/.agents/skills/add-enrichment/SKILL.md +++ b/.agents/skills/add-enrichment/SKILL.md @@ -22,8 +22,8 @@ Because enrichments run on Sim's hosted keys by default, **every provider tool y ## Architecture (what you're plugging into) -- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, projectFailure, mapOutput, isVerified? }` — `toolProvider` fills `projectFailure` with the standard HTTP projection; override it only for a provider whose failure shape is nonstandard (see `enrichments/provider-failures/`). Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe. -- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `firstNonEmpty(arr)`, `splitName(fullName)`, and `projectEnrichmentProviderFailure`. +- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, projectFailure, mapOutput, isVerified? }` — `toolProvider` fills `projectFailure` with the standard HTTP projection; override it only for a provider whose failure shape is nonstandard. Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe. +- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `splitName(fullName)`, and `projectEnrichmentProviderFailure`. - **`enrichments/run.ts`** — the server-only cascade runner. Calls `executeTool(provider.toolId, { ...params, _context: { workspaceId, userId } })`, accumulates hosted-key cost, returns the first non-empty mapped result. **You do not edit this** — it works for any registry entry. - **`enrichments/registry.ts`** — `ENRICHMENT_REGISTRY` / `ALL_ENRICHMENTS` / `getEnrichment`. Register new entries here. @@ -111,7 +111,7 @@ export { myEnrichment } from './my-enrichment' ``` Rules: -- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, `@/enrichments/provider-failures/*`, and the types. **Never import `@/tools`** here — the runner does the tool call. +- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call. - `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs). - Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config). diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index 53311498b56..7f8a3c7f155 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -411,7 +411,7 @@ export const {service}PollingHandler: PollingProviderHandler = { await markWebhookSuccess(webhookId, logger) return 'success' } catch (error) { - logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error) + logger.error('Error processing {service} webhook:', error) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -423,6 +423,7 @@ export const {service}PollingHandler: PollingProviderHandler = { - First poll seeds state and emits nothing (avoids flooding with existing data) - Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup - Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow +- Log through `ctx.logger` (already bound with `webhookId` and `pollId`, which equals `ctx.requestId`). Never add `{ requestId }` to a log line; the poll route's request context owns that field and overwrites it - Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state - Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew diff --git a/.agents/skills/emil-design-eng/SKILL.md b/.agents/skills/emil-design-eng/SKILL.md index 9ac52fe59da..6f9edf24c4d 100644 --- a/.agents/skills/emil-design-eng/SKILL.md +++ b/.agents/skills/emil-design-eng/SKILL.md @@ -6,7 +6,7 @@ description: This skill encodes Emil Kowalski's philosophy on UI polish, compone # Design Engineering -> **In this repo:** emcn owns component chrome (press states, tooltip/popover/menu animation, easing): apply these principles by proposing changes in `packages/emcn`, not per call site, and add no global CSS variables or keyframes (CLAUDE.md "Styling and EMCN"). Animation uses `import { motion } from 'framer-motion'`. Gate touch-unsafe hover with the `hover-hover:` Tailwind variant (plain `hover:` is intentionally ungated). `.claude/rules/sim-styling.md` and `.claude/rules/emcn-components.md` win on any conflict. +> **In this repo:** emcn owns component chrome (press states, tooltip/popover/menu animation, easing): apply these principles by proposing changes in `packages/emcn`, not per call site, and add no global CSS variables or keyframes (`.claude/rules/sim-styling.md`). Animation uses `import { motion } from 'framer-motion'`. Gate touch-unsafe hover with the `hover-hover:` Tailwind variant (plain `hover:` is intentionally ungated). `.claude/rules/sim-styling.md` and `.claude/rules/emcn-components.md` win on any conflict. You are a design engineer with the craft sensibility. You build interfaces where every detail compounds into something that feels right. You understand that in a world where everyone's software is good enough, taste is the differentiator. diff --git a/.agents/skills/make-interfaces-feel-better/SKILL.md b/.agents/skills/make-interfaces-feel-better/SKILL.md index 4e62a1392a8..dfb709f427a 100644 --- a/.agents/skills/make-interfaces-feel-better/SKILL.md +++ b/.agents/skills/make-interfaces-feel-better/SKILL.md @@ -49,7 +49,7 @@ Animate contextual icons with opacity, scale, and blur instead of toggling visib ### 8. Font Smoothing -Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text. In this repo, do not add root-level smoothing or other global styles (CLAUDE.md "Styling and EMCN"); raise it as a design-system proposal instead. +Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text. In this repo, do not add root-level smoothing or other global styles (`.claude/rules/sim-styling.md`); raise it as a design-system proposal instead. ### 9. Tabular Numbers diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 300c63f9260..2c5e16be354 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -3,6 +3,7 @@ description: Tailwind CSS and styling conventions paths: - "apps/sim/**/*.tsx" - "apps/sim/**/*.css" + - "packages/workflow-renderer/**/*.tsx" --- # Styling Rules @@ -89,6 +90,8 @@ Draw a line with a real `border-*` utility. Never hand-roll one as `shadow-[inse ## Chip Components (consumer usage) +The chip family is the canonical chrome: `ChipInput`, `ChipTextarea`, `ChipModal`/`ChipModalField`, `ChipSelect`/`ChipCombobox`/`ChipDropdown`, `ChipSwitch` (a segmented choice; a boolean toggle stays `Switch`), `ChipDatePicker`, `Chip`/`ChipLink`, `ChipTag`; `DropdownMenu` for context/action menus. Import components, `cn`, and tokens from the `@sim/emcn` barrel and icons from `@sim/emcn/icons`; never deep-import other component subpaths. Never update global styles; keep styling local to the component. + `ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `packages/emcn/src/components/chip/chrome.ts` (all tokens are re-exported from the `@sim/emcn` barrel — no subpath import needed) — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings. ### Props over className diff --git a/.claude/rules/sim-testing.md b/.claude/rules/sim-testing.md index 2559d7620d0..4250aa87088 100644 --- a/.claude/rules/sim-testing.md +++ b/.claude/rules/sim-testing.md @@ -5,6 +5,7 @@ paths: - "**/*.test.tsx" - "**/*.integration.ts" - "**/e2e/**" + - "apps/sim/scripts/test-*-e2e.ts" --- # Testing diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc index 3f00af5afff..f016cfc5b3f 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -1,6 +1,6 @@ --- description: "Tailwind CSS and styling conventions" -globs: ["apps/sim/**/*.tsx","apps/sim/**/*.css"] +globs: ["apps/sim/**/*.tsx","apps/sim/**/*.css","packages/workflow-renderer/**/*.tsx"] --- @@ -89,6 +89,8 @@ Draw a line with a real `border-*` utility. Never hand-roll one as `shadow-[inse ## Chip Components (consumer usage) +The chip family is the canonical chrome: `ChipInput`, `ChipTextarea`, `ChipModal`/`ChipModalField`, `ChipSelect`/`ChipCombobox`/`ChipDropdown`, `ChipSwitch` (a segmented choice; a boolean toggle stays `Switch`), `ChipDatePicker`, `Chip`/`ChipLink`, `ChipTag`; `DropdownMenu` for context/action menus. Import components, `cn`, and tokens from the `@sim/emcn` barrel and icons from `@sim/emcn/icons`; never deep-import other component subpaths. Never update global styles; keep styling local to the component. + `ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `packages/emcn/src/components/chip/chrome.ts` (all tokens are re-exported from the `@sim/emcn` barrel — no subpath import needed) — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings. ### Props over className diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index 9f7f523930d..d4906ad3d77 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -1,6 +1,6 @@ --- description: "Test layers, naming, and Vitest mechanics for Sim" -globs: ["**/*.test.ts","**/*.test.tsx","**/*.integration.ts","**/e2e/**"] +globs: ["**/*.test.ts","**/*.test.tsx","**/*.integration.ts","**/e2e/**","apps/sim/scripts/test-*-e2e.ts"] --- diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f866dc555d9..00000000000 --- a/AGENTS.md +++ /dev/null @@ -1,164 +0,0 @@ -# Sim Development Guidelines - -This file (also `AGENTS.md`) holds the repo-wide rules. Area detail lives in `.claude/rules/*.md` (indexed in `apps/sim/AGENTS.md`): Claude loads each one by path, and any other agent reads the file a section points to before editing in that area. Skills live in `.agents/skills/`. - -## Global Standards - -- **Package manager**: `bun` and `bunx`, never `npm` and `npx`. -- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Inside `withRouteHandler` the logger already carries the request ID — no manual `withMetadata({ requestId })`. -- **Comments**: name things so the code explains itself. TSDoc documents exported APIs and non-obvious modules. An inline `//` is only for a terse, non-obvious *why*, or for a script-enforced `// : ` annotation (`boundary-raw-fetch`, `double-cast-allowed`, `boundary-raw-json`, `untyped-response`, `rq-lint-allow`, `client-boundary-allow`, `utils-lint-allow`, …). History belongs in the commit message. No `====` separators, commented-out code, or ESLint directives (`eslint-disable`/`eslint-enable`; nothing runs ESLint; `check:comment-hygiene` enforces all three). The `/you-might-not-need-a-comment` skill applies this to a diff. -- **ID generation**: `generateId()` (UUID v4, the default) or `generateShortId(size?)` (URL-safe, 21 chars by default) from `@sim/utils/id` — never `crypto.randomUUID()`, `nanoid`, or `uuid`. Both use `crypto.getRandomValues()`, so they also work in non-secure (HTTP) browsers. For other randomness, `@sim/utils/random` (`randomInt`, `randomFloat`, `randomItem`, `generateRandomBytes`, `generateRandomHex`) — never `Math.random()` or `crypto.randomBytes()`. -- **Common utilities**: use the shared helpers from the `@sim/utils/` subpaths instead of inline implementations (`check:utils` bans most of the inline forms below): - - `sleep(ms)` from `@sim/utils/helpers` — never `new Promise(resolve => setTimeout(resolve, ms))` - - `toError(e)` from `@sim/utils/errors` — normalize caught values to `Error`; never `e instanceof Error ? e : new Error(String(e))` - - `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — never `e instanceof Error ? e.message : 'fallback'` - - `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))` - - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — never `Object.fromEntries(Object.entries(...).filter(...))` - - `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` - - `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value; never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts - - `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload; never declare a local one-liner byte-identical to one of these. Keep a local helper that differs: `undefined` instead of `null` changes the wire shape, and a `Number.isFinite` or string-parse variant is a stricter check these omit - - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - - `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` - - `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there - - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — never reimplement exponential backoff inline -- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a recovered tab would render Sim Cloud as self-hosted; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags`. -- **Type-checking**: `bun run type-check` at the root checks every workspace (`bun run type-check` inside a workspace checks just that one). `apps/sim`'s tsconfig excludes `*.test.ts(x)`, so `type-check` skips its tests; CI's `test-types` job ratchets their errors per file and TS code instead (`check:test-types`, whose header says how to fix a finding): a test file may not gain one. Never remove the `@typescript/native` alias from the root `devDependencies`: nothing imports it, but without it a bare `tsc` resolves to the ~10x slower TypeScript 6 compiler. `bun run check:native-typecheck` enforces this; its header explains the bin-resolution trap. - -## Architecture - -### Repository layout - -``` -apps/ -├── sim/ # Next.js app: UI, API routes, workflow builder, executor -│ ├── app/ # App router — pages and API routes (app/api/**) -│ ├── blocks/ # Block definitions and registry -│ ├── tools/ # Tool definitions and registry -│ ├── triggers/ # Trigger definitions and registry -│ ├── connectors/ # Knowledge base connectors -│ ├── executor/ # Workflow execution engine -│ ├── providers/ # LLM provider integrations -│ ├── components/ # Shared app UI (ui/, icons, …) -│ ├── hooks/ # Shared hooks (queries/, selectors/) -│ ├── stores/ # Zustand stores -│ ├── lib/ # App-wide modules, incl. lib/api/contracts and lib//application -│ └── ee/ # Enterprise features -├── realtime/ # Bun Socket.IO server (collaborative workflow builder) -├── desktop/ # Electron shell around the hosted web app -├── docs/ # Documentation site -└── pii/ # Python PII detection service (not in the JS/turbo build) - -packages/ -├── emcn/ # @sim/emcn — design system (chip family, tokens, icons) -├── db/ # @sim/db — Drizzle schema, migrations, client -├── auth/ # @sim/auth — shared Better Auth verifier -├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports) -├── audit/ logger/ security/ utils/ runtime-secrets/ deployment-config/ -├── realtime-protocol/ browser-protocol/ terminal-protocol/ desktop-bridge/ -├── workflow-types/ workflow-persistence/ workflow-renderer/ -├── testing/ # @sim/testing — test factories and mocks -├── tsconfig/ # shared tsconfig presets -└── cli/ sim-cli/ sim-setup/ ts-sdk/ python-sdk/ # published CLIs and SDKs -``` - -### Package boundaries - -- `apps/* → packages/*` only. Packages never import from `apps/*`. -- `apps/realtime` avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. Never add imports from `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` to any package it consumes; it calls back into `apps/sim` only over internal HTTP with `INTERNAL_API_SECRET`. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`. -- Auth is shared across both apps via the Better Auth "Shared Database Session" pattern (same `BETTER_AUTH_SECRET`, same DB via `@sim/db`). - -### Application Operation Boundary - -- Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through an authorized application use case. -- Define one stable semantic operation with its minimum role, workspace-key policy, allowed principal kinds, and delegated services. Internal APIs, v2 APIs, Copilot, and trusted tools call the same use case when the domain behavior is the same. -- Surface adapters authenticate and construct a `Principal`, apply request-rate policy, parse contracts, map input, and present their own result. They never query protected data, decide resource authorization, implement business transactions, or record semantic audit (`check:source-conventions` ratchets value imports of `@sim/db` under `app/api/`). -- Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral (`check:boundaries`): never `next/server` or `app/api/**`, and never a runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine); a surface fact, such as a cursor's route, comes in as input. -- Copilot is a surface adapter. Use `createCopilotApplicationAdapter` and the domain's registered operation object; never a Copilot-only authorization or business implementation. -- Protected compound mutations belong in one top-level semantic application operation, never a sequence of independently committing mutations in a route or tool adapter. -- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller. -- Use the `migrate-application-operation` skill whenever creating or migrating a protected endpoint, tool command, or resource method. - -The `'use client'` server boundary, the app/worker runtime env split, and feature folder layout are in `.claude/rules/sim-architecture.md`. - -## Code Conventions - -- **Naming**: components PascalCase (`WorkflowList`); hooks `use*`; files kebab-case (`workflow-list.tsx`); constants SCREAMING_SNAKE_CASE; interfaces PascalCase with a suffix (`WorkflowListProps`); stores `stores//store.ts`. Inside `lib/`, `executor/`, `providers/`, `stores/`, `hooks/`, `serializer/`, and a package's `src/`, a file never starts with its folder's name (`logs/views.ts`, not `logs/log-views.ts`), and a file in `utils/` or `helpers/` never repeats that role (`date.ts`, not `date-utils.ts`); `feature/feature.tsx` stays the component convention. `check:file-names` ratchets this. -- **Imports**: absolute (`@/...`) only, never relative (a barrel `index.ts` re-exports its own siblings relatively). `check:source-conventions` ratchets relative imports in `apps/sim`. A folder with 3+ exports gets an `index.ts` barrel; never re-export from a non-barrel file. `import type` for type-only imports. Order and lazy-loading through barrels: `.claude/rules/sim-imports.md`. -- **TypeScript**: no `any` and no non-null `!` (use precise types or `unknown` with guards; `check:explicit-any` ratchets both); no export nothing imports (`check:unused-exports`); a props interface for every component; `as const` for constant objects/arrays; explicit ref types (`useRef(null)`). -- **Unused bindings** fail lint (biome `noUnusedVariables`, `noUnusedFunctionParameters`): delete the dead variable, import, or parameter and update callers; write `catch {}` when the error is unused. Prefix `_` only for a parameter that must hold its position because a later one is used, or a type parameter kept for API shape. `const { a, ...rest } = obj` to omit keys is allowed. The rules carry no autofix, so `bun run lint` will not rename anything for you. -- **Components**: `'use client'` only for hooks or browser APIs (`check:client-boundary` guards the server boundary). Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()`, never `toSorted()`): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI. -- **State ownership**: React Query owns server data — never `useState` + `fetch`; shareable client view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks: `.claude/rules/sim-hooks.md`. Stores (`devtools`, `persist` only with an explicit `partialize` whitelist, workflow value invariants): `.claude/rules/sim-stores.md`. URL state: `.claude/rules/sim-url-state.md`. -- **Utils**: inline a helper with one consumer; create `utils.ts` when 2+ files share it — in `lib/` (app-wide) or `feature/utils/` (feature-scoped). Check `lib/` before writing a new one. -- **Lists and menus** mirror the order the user already reads elsewhere (toolbar, settings nav), encoded in one exported order constant (resource menus share `RESOURCE_MENU_ORDER`, a product order that does not mirror the sidebar); a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`. -- **Caching**: `lru-cache` with a `max` ceiling, never a hand-rolled TTL `Map`; a lifecycle map is not a cache; cache the gate, never the credential: `.claude/rules/sim-caching.md`. - -## API Contracts and Routes - -- Request/response shapes for every route under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**`, built with `defineRouteContract`. Export the contract; export a named schema or type alias only when another module imports it (`check:unused-exports`). Routes never import `zod` or define route-local boundary schemas; clients never write ad-hoc wire types or `z.input`/`z.output`. -- Every route handler runs inside `withRouteHandler`. Ordinary internal and v2 routes use the shared builders (`defineInternalJsonRoute`, `defineV2JsonRoute`, binary/stream variants), which already apply it — never double-wrap. Raw `withRouteHandler` is only for documented protocol or lifecycle exceptions. Never export a bare `async function GET/POST/...` (`check:source-conventions`). -- Same-origin JSON calls go through `requestJson(contract, ...)` from `@/lib/api/client/request`. A raw `fetch` is only for streaming, binary downloads, multipart uploads, signed URLs, OAuth redirects, or external origins, and carries `// boundary-raw-fetch: `. -- The other script-enforced exceptions are `// double-cast-allowed:`, `// boundary-raw-json:`, and `// untyped-response:`. Never add one to silence a fixable finding. -- `bun run check:api-validation:strict` must pass. Full contract rules, route pattern, annotation placement, the end-to-end order, and the schema review checklist: `.claude/rules/sim-api-contracts.md`. React Query hooks (key factories, named `staleTime` constants, `signal`, invalidation, server prefetch): `.claude/rules/sim-queries.md`. - -## Styling and EMCN - -- Tailwind only. Inline `style` only for a genuinely dynamic value or a CSS variable; type size is a named token, never `text-[Npx]` (`check:source-conventions` ratchets both). Never update global styles; keep styling local to the component. `cn()` from `@sim/emcn` for conditional classes. `size-*` for equal height and width (icons default `size-[14px]`), never `h-N w-N`. -- Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons from `@sim/emcn/icons`; CSS modules by file path. Never deep-import other component subpaths. -- The chip family is the canonical chrome: `ChipInput`, `ChipTextarea`, `ChipModal`/`ChipModalField`, `ChipSelect`/`ChipCombobox`/`ChipDropdown`, `ChipSwitch` (a segmented choice; a boolean toggle stays `Switch`), `ChipDatePicker`, `Chip`/`ChipLink`, `ChipTag`; `DropdownMenu` for context/action menus. Components own their chrome: consumers pass props (`error`, `icon`, `endAdornment`, `inputClassName`) and `className` carries only layout/sizing. Every labeled field inside a `ChipModalBody` is a `ChipModalField`. -- Consumer rules, tokens, text scale, and modal rhythm: `.claude/rules/sim-styling.md`. Authoring components in `packages/emcn`: `.claude/rules/emcn-components.md`. Product UI copy: `.claude/rules/sim-ui-copy.md`. Marketing copy and positioning: `.claude/rules/constitution.md`. - -## Testing - -Most unit tests in a codebase like this restate the code they test. They pass on the first run, break on every refactor, and catch nothing that type-check, `next build`, `bun run check:audits`, or a real end-to-end run would miss. Test for confidence, not coverage. - -- **Never write unit tests after you write code.** A test written to describe code that already exists restates the implementation and proves nothing. If the change needs proof, prove it end to end. -- **Highly prefer E2E tests.** Use them to verify complex features work, against the real boundary: real Postgres/Redis (`*.integration.ts`), the running app over real HTTP (`apps/sim/scripts/test-*-e2e.ts`), or the packaged desktop app (`apps/desktop/e2e`, Playwright). At the end of an E2E test, produce a verifiable and repeatable artifact — a JSON report of each check with status and duration, an HTTP status log, a trace, or a screenshot — written to a caller-supplied `_REPORT_PATH` and uploaded by CI on failure. `apps/sim/scripts/test-scim-e2e.ts` is the reference. -- **If you must test a system in isolation, first write down all the ways it could fail, then write the code.** Each failure mode (bad input, boundary, concurrency, partial failure, permission denial, resource cap) becomes one test that fails before the code exists. -- A regression test must fail on the pre-fix code. Revert each guard of the fix and watch its test go red before you trust it. -- Never write tests that restate declarations (block/tool/provider config, registries, constants, schemas accepting valid input), mock every collaborator and assert `toHaveBeenCalledWith` on the mocks, check rendered text or class names, or test mocks and factories themselves. -- Never hand-roll a mock or test helper that `apps/sim/vitest.setup.ts` or `@sim/testing` already provides; a module mocked in a third file gets one central mock. `bun run check:test-patterns` enforces this. - -Use the `test-audit` skill whenever you write, change, review, or sweep tests — it holds the authoring gate, the junk patterns, and the retention bar. Test layers, file naming, and Vitest mechanics (global mocks, `@sim/testing`, performance rules) are in `.claude/rules/sim-testing.md`. - -## Integrations - -Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, starting from the service's API docs. Use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`. Two rules the skills assume: - -- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`, alphabetically). -- **Type coercions go in `tools.config.params`** (runs at execution, after variable resolution), never in `tools.config.tool` (runs at serialization, where `Number()` destroys dynamic `` references). - -A surface that lists integrations to a viewer (a catalog, picker, or `@` menu) reads `useIntegrationAccess` (`hooks/use-integration-access.ts`): the one verdict across permission group, deployment allowlist, and deployment OAuth configuration, which fails closed until the viewer's policy loads. Never map over a raw catalog (`INTEGRATIONS`, `listIntegrations()`, `INTEGRATION_METADATA`) there; `check:integration-access` fails the build. - -Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`. - -## Tables - -Table column types are registry entries in `apps/sim/lib/table/column-types/` — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. `Record` on `registry.ts` and `registry.server.ts` is a compile-time completeness gate: adding a type to the union errors until both entries exist. - -Never add a `case 'sometype':` outside `column-types/`, except the one documented import-path switch (`coerceValue` in `lib/table/import.ts`), which every new type extends — a missing arm fails silently (a wrong `jsonbCast` breaks every filter on the column). If a consumer needs per-type knowledge, add a registry field. Use `/add-column-type` for the full procedure. - -## How your work is checked - -Before declaring a change done, run the local gate from the repo root; CI runs these and fails on any of them: - -```bash -bun run lint # biome format + lint, autofixes (CI runs lint:check) -bun run type-check # every workspace -bun run check:audits # every check:* audit, generated-artifact checks included -bun run test # script tests, then every workspace's Vitest suite -git fetch origin staging # the block-registry check diffs against it -bun run apps/sim/scripts/check-block-registry.ts origin/staging -``` - -Author a `packages/db` schema change or migration with the `db-migrate` skill. CI also runs `bun run check:migrations` (it diffs against a base ref, `origin/staging` by default, so it is not in `check:audits`; run it when you touch `packages/db/migrations/**`), checks that `drizzle-kit generate` in `packages/db` produces no new migration, and runs a non-blocking `bun audit`. When an audit fails, its output and its script's header say what the rule protects; fix the code, never the check. Ratchet baselines (`scripts/*baseline.json`) only shrink: regenerate one with `--update` after removing violations; it refuses to admit new debt. The one exception is `check:tool-registry-boundary`, whose module-count baseline is re-recorded with `--update-baseline` when growth is deliberate (see its skill). - - - -# This is NOT the Turborepo you know - -Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`. - -Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access. - -This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. - diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000000..681311eb9cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 981af4c0214..dc98057cab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,145 +1,71 @@ # Sim Development Guidelines -This file (also `AGENTS.md`) holds the repo-wide rules. Area detail lives in `.claude/rules/*.md` (indexed in `apps/sim/AGENTS.md`): Claude loads each one by path, and any other agent reads the file a section points to before editing in that area. Skills live in `.agents/skills/`. +This file (also `AGENTS.md`) holds the repo-wide rules: Global Standards, Architecture (incl. the Application Operation Boundary), Code Conventions, API Contracts and Routes, Testing, Integrations, and How your work is checked (the local gate to run before calling a change done). Area detail lives in `.claude/rules/*.md`, indexed in `apps/sim/AGENTS.md`: Claude loads each one by path, and any other agent reads the file a section points to before editing in that area. Skills live in `.agents/skills/`. + +Most rules here are enforced by a `check:*` audit or biome. When one fails, its message and its script's header say what the rule protects and how to fix it: fix the code, never the check, and never add an annotation (`// boundary-raw-fetch:`, `// double-cast-allowed:`, …) to silence a fixable finding. ## Global Standards - **Package manager**: `bun` and `bunx`, never `npm` and `npx`. -- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Inside `withRouteHandler` the logger already carries the request ID — no manual `withMetadata({ requestId })`. -- **Comments**: name things so the code explains itself. TSDoc documents exported APIs and non-obvious modules. An inline `//` is only for a terse, non-obvious *why*, or for a script-enforced `// : ` annotation (`boundary-raw-fetch`, `double-cast-allowed`, `boundary-raw-json`, `untyped-response`, `rq-lint-allow`, `client-boundary-allow`, `utils-lint-allow`, …). History belongs in the commit message. No `====` separators, commented-out code, or ESLint directives (`eslint-disable`/`eslint-enable`; nothing runs ESLint; `check:comment-hygiene` enforces all three). The `/you-might-not-need-a-comment` skill applies this to a diff. -- **ID generation**: `generateId()` (UUID v4, the default) or `generateShortId(size?)` (URL-safe, 21 chars by default) from `@sim/utils/id` — never `crypto.randomUUID()`, `nanoid`, or `uuid`. Both use `crypto.getRandomValues()`, so they also work in non-secure (HTTP) browsers. For other randomness, `@sim/utils/random` (`randomInt`, `randomFloat`, `randomItem`, `generateRandomBytes`, `generateRandomHex`) — never `Math.random()` or `crypto.randomBytes()`. -- **Common utilities**: use the shared helpers from the `@sim/utils/` subpaths instead of inline implementations (`check:utils` bans most of the inline forms below): - - `sleep(ms)` from `@sim/utils/helpers` — never `new Promise(resolve => setTimeout(resolve, ms))` - - `toError(e)` from `@sim/utils/errors` — normalize caught values to `Error`; never `e instanceof Error ? e : new Error(String(e))` - - `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — never `e instanceof Error ? e.message : 'fallback'` - - `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))` - - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — never `Object.fromEntries(Object.entries(...).filter(...))` - - `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` - - `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value; never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts - - `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload; never declare a local one-liner byte-identical to one of these. Keep a local helper that differs: `undefined` instead of `null` changes the wire shape, and a `Number.isFinite` or string-parse variant is a stricter check these omit - - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - - `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` - - `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there - - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — never reimplement exponential backoff inline -- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a recovered tab would render Sim Cloud as self-hosted; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags`. -- **Type-checking**: `bun run type-check` at the root checks every workspace (`bun run type-check` inside a workspace checks just that one). `apps/sim`'s tsconfig excludes `*.test.ts(x)`, so `type-check` skips its tests; CI's `test-types` job ratchets their errors per file and TS code instead (`check:test-types`, whose header says how to fix a finding): a test file may not gain one. Never remove the `@typescript/native` alias from the root `devDependencies`: nothing imports it, but without it a bare `tsc` resolves to the ~10x slower TypeScript 6 compiler. `bun run check:native-typecheck` enforces this; its header explains the bin-resolution trap. +- **Logging**: `createLogger` from `@sim/logger`, never `console.log`. Messages are static text and ids go in metadata (`logger.info('Sync started', { documentId })`); inside `withRouteHandler` every line already carries the request ID. +- **Comments**: name things so the code explains itself. TSDoc on exported APIs and non-obvious modules; an inline `//` only for a terse, non-obvious *why* or a script-enforced `// : ` annotation. History goes in the commit message. No `====` separators, commented-out code, or ESLint directives (`check:comment-hygiene`). +- **Common utilities**: use the `@sim/utils/` helpers (`id`, `random`, `errors`, `object`, `coerce`, `string`, `helpers`, `retry`) over inline versions. IDs are `generateId()` (default) or `generateShortId()` from `@sim/utils/id`, never `randomUUID`, `nanoid`, or `uuid`: the helpers also work in non-secure (HTTP) browsers. `check:utils` bans the inline forms and names the replacement. It cannot catch these, so reach for them yourself: `toArray` and `toStringOrNull`/`toNumberOrNull`/`toBooleanOrNull` for untyped payloads, `compareStrings` (never `localeCompare`) for hashes and cross-process ordering, and `backoffWithJitter`/`parseRetryAfter` for retries. Where the source is already typed, keep an inline `Array.isArray` check (it narrows; `toArray` asserts), and keep a local helper that deliberately differs (returns `undefined` instead of `null`, or a stricter `Number.isFinite` check). +- **Deployment flags in the browser**: client code reads `hosted`, `billingEnabled`, `chatEnabled`, and enterprise flags through `useDeploymentShape()` / `getDeploymentShape()` from `@/lib/core/config/deployment-shape`, never `env-flags` (its values freeze wrong on 404 and error shells). Server code keeps `env-flags` (`check:client-boundary`). +- **Type-checking**: `bun run type-check` at the root checks every workspace (inside a workspace it checks just that one). `apps/sim` excludes `*.test.ts(x)` from it; CI's `check:test-types` ratchets their errors per file instead. Keep the unused `@typescript/native` root devDependency (`check:native-typecheck`). ## Architecture -### Repository layout - -``` -apps/ -├── sim/ # Next.js app: UI, API routes, workflow builder, executor -│ ├── app/ # App router — pages and API routes (app/api/**) -│ ├── blocks/ # Block definitions and registry -│ ├── tools/ # Tool definitions and registry -│ ├── triggers/ # Trigger definitions and registry -│ ├── connectors/ # Knowledge base connectors -│ ├── executor/ # Workflow execution engine -│ ├── providers/ # LLM provider integrations -│ ├── components/ # Shared app UI (ui/, icons, …) -│ ├── hooks/ # Shared hooks (queries/, selectors/) -│ ├── stores/ # Zustand stores -│ ├── lib/ # App-wide modules, incl. lib/api/contracts and lib//application -│ └── ee/ # Enterprise features -├── realtime/ # Bun Socket.IO server (collaborative workflow builder) -├── desktop/ # Electron shell around the hosted web app -├── docs/ # Documentation site -└── pii/ # Python PII detection service (not in the JS/turbo build) - -packages/ -├── emcn/ # @sim/emcn — design system (chip family, tokens, icons) -├── db/ # @sim/db — Drizzle schema, migrations, client -├── auth/ # @sim/auth — shared Better Auth verifier -├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports) -├── audit/ logger/ security/ utils/ runtime-secrets/ deployment-config/ -├── realtime-protocol/ browser-protocol/ terminal-protocol/ desktop-bridge/ -├── workflow-types/ workflow-persistence/ workflow-renderer/ -├── testing/ # @sim/testing — test factories and mocks -├── tsconfig/ # shared tsconfig presets -└── cli/ sim-cli/ sim-setup/ ts-sdk/ python-sdk/ # published CLIs and SDKs -``` - -### Package boundaries - -- `apps/* → packages/*` only. Packages never import from `apps/*`. -- `apps/realtime` avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. Never add imports from `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` to any package it consumes; it calls back into `apps/sim` only over internal HTTP with `INTERNAL_API_SECRET`. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`. -- Auth is shared across both apps via the Better Auth "Shared Database Session" pattern (same `BETTER_AUTH_SECRET`, same DB via `@sim/db`). +- **Layout**: `apps/sim` is the Next.js app (UI, API routes, executor; use cases live in `lib//application`), `apps/realtime` the collaborative Socket.IO server, `apps/desktop` an Electron shell around the hosted web app, `apps/docs` the docs site, and `apps/pii` a Python service outside the JS/turbo build. Shared code is in `packages/*` (`@sim/*`). +- **Package boundaries**: `apps/* → packages/*` only; packages never import from `apps/*`. `apps/realtime` (a Bun Socket.IO server) avoids Next.js, React, the block/tool registry, provider SDKs, and the executor, and calls `apps/sim` only over internal HTTP with `INTERNAL_API_SECRET` (`check:boundaries`, `check:realtime-prune`). Both apps share Better Auth sessions through `@sim/db` and the same `BETTER_AUTH_SECRET`. +- `'use client'` boundary, app/worker env split, and feature folder layout: `.claude/rules/sim-architecture.md`. ### Application Operation Boundary -- Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through an authorized application use case. -- Define one stable semantic operation with its minimum role, workspace-key policy, allowed principal kinds, and delegated services. Internal APIs, v2 APIs, Copilot, and trusted tools call the same use case when the domain behavior is the same. -- Surface adapters authenticate and construct a `Principal`, apply request-rate policy, parse contracts, map input, and present their own result. They never query protected data, decide resource authorization, implement business transactions, or record semantic audit (`check:source-conventions` ratchets value imports of `@sim/db` under `app/api/`). -- Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral (`check:boundaries`): never `next/server` or `app/api/**`, and never a runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine); a surface fact, such as a cursor's route, comes in as input. -- Copilot is a surface adapter. Use `createCopilotApplicationAdapter` and the domain's registered operation object; never a Copilot-only authorization or business implementation. -- Protected compound mutations belong in one top-level semantic application operation, never a sequence of independently committing mutations in a route or tool adapter. -- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller. -- Use the `migrate-application-operation` skill whenever creating or migrating a protected endpoint, tool command, or resource method. - -The `'use client'` server boundary, the app/worker runtime env split, and feature folder layout are in `.claude/rules/sim-architecture.md`. +- Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through one authorized application use case with its minimum role, workspace-key policy, and allowed principal kinds. Internal APIs, v2 APIs, Copilot (via `createCopilotApplicationAdapter`), and trusted tools call the same use case for the same domain behavior. +- Surface adapters authenticate, build a `Principal`, apply rate policy, parse contracts, and present results. They never query protected data, decide authorization, run business transactions, or record semantic audit (`check:source-conventions` ratchets value imports of `@sim/db` under `app/api/`). Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral (`check:boundaries`): no `next/server`, `app/api/**`, or runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine). +- A protected compound mutation is one top-level operation, never a sequence of independently committing mutations in an adapter. +- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal; fail fast when the identity model cannot express the caller. +- Use the `migrate-application-operation` skill for any new or migrated protected endpoint, tool command, or resource method. ## Code Conventions -- **Naming**: components PascalCase (`WorkflowList`); hooks `use*`; files kebab-case (`workflow-list.tsx`); constants SCREAMING_SNAKE_CASE; interfaces PascalCase with a suffix (`WorkflowListProps`); stores `stores//store.ts`. Inside `lib/`, `executor/`, `providers/`, `stores/`, `hooks/`, `serializer/`, and a package's `src/`, a file never starts with its folder's name (`logs/views.ts`, not `logs/log-views.ts`), and a file in `utils/` or `helpers/` never repeats that role (`date.ts`, not `date-utils.ts`); `feature/feature.tsx` stays the component convention. `check:file-names` ratchets this. -- **Imports**: absolute (`@/...`) only, never relative (a barrel `index.ts` re-exports its own siblings relatively). `check:source-conventions` ratchets relative imports in `apps/sim`. A folder with 3+ exports gets an `index.ts` barrel; never re-export from a non-barrel file. `import type` for type-only imports. Order and lazy-loading through barrels: `.claude/rules/sim-imports.md`. -- **TypeScript**: no `any` and no non-null `!` (use precise types or `unknown` with guards; `check:explicit-any` ratchets both); no export nothing imports (`check:unused-exports`); a props interface for every component; `as const` for constant objects/arrays; explicit ref types (`useRef(null)`). -- **Unused bindings** fail lint (biome `noUnusedVariables`, `noUnusedFunctionParameters`): delete the dead variable, import, or parameter and update callers; write `catch {}` when the error is unused. Prefix `_` only for a parameter that must hold its position because a later one is used, or a type parameter kept for API shape. `const { a, ...rest } = obj` to omit keys is allowed. The rules carry no autofix, so `bun run lint` will not rename anything for you. -- **Components**: `'use client'` only for hooks or browser APIs (`check:client-boundary` guards the server boundary). Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()`, never `toSorted()`): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI. -- **State ownership**: React Query owns server data — never `useState` + `fetch`; shareable client view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks: `.claude/rules/sim-hooks.md`. Stores (`devtools`, `persist` only with an explicit `partialize` whitelist, workflow value invariants): `.claude/rules/sim-stores.md`. URL state: `.claude/rules/sim-url-state.md`. -- **Utils**: inline a helper with one consumer; create `utils.ts` when 2+ files share it — in `lib/` (app-wide) or `feature/utils/` (feature-scoped). Check `lib/` before writing a new one. -- **Lists and menus** mirror the order the user already reads elsewhere (toolbar, settings nav), encoded in one exported order constant (resource menus share `RESOURCE_MENU_ORDER`, a product order that does not mirror the sidebar); a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`. -- **Caching**: `lru-cache` with a `max` ceiling, never a hand-rolled TTL `Map`; a lifecycle map is not a cache; cache the gate, never the credential: `.claude/rules/sim-caching.md`. +- **Naming**: components PascalCase, hooks `use*`, files kebab-case, constants SCREAMING_SNAKE_CASE, interfaces with a suffix (`WorkflowListProps`), stores at `stores//store.ts`. Inside `lib/`, `executor/`, `providers/`, `stores/`, `hooks/`, `serializer/`, and a package's `src/`, a file never starts with its folder's name (`logs/views.ts`, not `logs/log-views.ts`; `feature/feature.tsx` stays the component convention), and a file in `utils/` or `helpers/` never repeats that role (`date.ts`, not `date-utils.ts`) (`check:file-names`). +- **Imports**: absolute (`@/...`) only; a folder with 3+ exports gets an `index.ts` barrel, and nothing re-exports from a non-barrel file; `import type` for type-only imports (`.claude/rules/sim-imports.md`). +- **TypeScript**: no `any` (use precise types or `unknown` with guards), no non-null `!`, no export nothing imports (`check:explicit-any`, `check:unused-exports`). Unused bindings fail biome with no autofix: delete them and update callers, and write `catch {}` when the error is unused. `_` only for a parameter that must hold its position or a type parameter kept for API shape; `const { a, ...rest } = obj` is fine. +- **Utils**: check `lib/` and the `@sim/utils/` helpers before writing a helper. Inline a helper with one consumer; create `utils.ts` (in `lib/` or `feature/utils/`) only when 2+ files share it. +- **Caching**: `lru-cache` with a `max` ceiling, never a hand-rolled TTL `Map`; cache the gate, never the credential (`.claude/rules/sim-caching.md`). +- **State ownership**: React Query owns server data (never `useState` + `fetch`); shareable view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks, queries, stores, URL state, components, lists and menus, and render performance each have a rule file in `.claude/rules/`. +- **UI**: Tailwind and the `@sim/emcn` chip family (`.claude/rules/sim-styling.md`); product copy `.claude/rules/sim-ui-copy.md`; marketing copy `.claude/rules/constitution.md`. ## API Contracts and Routes -- Request/response shapes for every route under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**`, built with `defineRouteContract`. Export the contract; export a named schema or type alias only when another module imports it (`check:unused-exports`). Routes never import `zod` or define route-local boundary schemas; clients never write ad-hoc wire types or `z.input`/`z.output`. -- Every route handler runs inside `withRouteHandler`. Ordinary internal and v2 routes use the shared builders (`defineInternalJsonRoute`, `defineV2JsonRoute`, binary/stream variants), which already apply it — never double-wrap. Raw `withRouteHandler` is only for documented protocol or lifecycle exceptions. Never export a bare `async function GET/POST/...` (`check:source-conventions`). -- Same-origin JSON calls go through `requestJson(contract, ...)` from `@/lib/api/client/request`. A raw `fetch` is only for streaming, binary downloads, multipart uploads, signed URLs, OAuth redirects, or external origins, and carries `// boundary-raw-fetch: `. -- The other script-enforced exceptions are `// double-cast-allowed:`, `// boundary-raw-json:`, and `// untyped-response:`. Never add one to silence a fixable finding. -- `bun run check:api-validation:strict` must pass. Full contract rules, route pattern, annotation placement, the end-to-end order, and the schema review checklist: `.claude/rules/sim-api-contracts.md`. React Query hooks (key factories, named `staleTime` constants, `signal`, invalidation, server prefetch): `.claude/rules/sim-queries.md`. - -## Styling and EMCN - -- Tailwind only. Inline `style` only for a genuinely dynamic value or a CSS variable; type size is a named token, never `text-[Npx]` (`check:source-conventions` ratchets both). Never update global styles; keep styling local to the component. `cn()` from `@sim/emcn` for conditional classes. `size-*` for equal height and width (icons default `size-[14px]`), never `h-N w-N`. -- Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons from `@sim/emcn/icons`; CSS modules by file path. Never deep-import other component subpaths. -- The chip family is the canonical chrome: `ChipInput`, `ChipTextarea`, `ChipModal`/`ChipModalField`, `ChipSelect`/`ChipCombobox`/`ChipDropdown`, `ChipSwitch` (a segmented choice; a boolean toggle stays `Switch`), `ChipDatePicker`, `Chip`/`ChipLink`, `ChipTag`; `DropdownMenu` for context/action menus. Components own their chrome: consumers pass props (`error`, `icon`, `endAdornment`, `inputClassName`) and `className` carries only layout/sizing. Every labeled field inside a `ChipModalBody` is a `ChipModalField`. -- Consumer rules, tokens, text scale, and modal rhythm: `.claude/rules/sim-styling.md`. Authoring components in `packages/emcn`: `.claude/rules/emcn-components.md`. Product UI copy: `.claude/rules/sim-ui-copy.md`. Marketing copy and positioning: `.claude/rules/constitution.md`. +- Request/response shapes for every route under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` via `defineRouteContract`. Routes never import `zod`; clients never hand-write wire types. +- Handlers use the shared builders (`defineInternalJsonRoute`, `defineV2JsonRoute`, binary/stream variants), which already apply `withRouteHandler`; never double-wrap or export a bare handler. +- Same-origin JSON calls use `requestJson(contract, ...)` from `@/lib/api/client/request`. A raw `fetch` is only for streaming, binary, multipart, signed URLs, OAuth redirects, or external origins, and carries `// boundary-raw-fetch: `. +- `bun run check:api-validation:strict` must pass. Full rules: `.claude/rules/sim-api-contracts.md`. ## Testing -Most unit tests in a codebase like this restate the code they test. They pass on the first run, break on every refactor, and catch nothing that type-check, `next build`, `bun run check:audits`, or a real end-to-end run would miss. Test for confidence, not coverage. +Most unit tests restate the code they test: they pass first time, break on every refactor, and catch nothing type-check, the build, the audits, or a real run would miss. Test for confidence, not coverage. -- **Never write unit tests after you write code.** A test written to describe code that already exists restates the implementation and proves nothing. If the change needs proof, prove it end to end. -- **Highly prefer E2E tests.** Use them to verify complex features work, against the real boundary: real Postgres/Redis (`*.integration.ts`), the running app over real HTTP (`apps/sim/scripts/test-*-e2e.ts`), or the packaged desktop app (`apps/desktop/e2e`, Playwright). At the end of an E2E test, produce a verifiable and repeatable artifact — a JSON report of each check with status and duration, an HTTP status log, a trace, or a screenshot — written to a caller-supplied `_REPORT_PATH` and uploaded by CI on failure. `apps/sim/scripts/test-scim-e2e.ts` is the reference. -- **If you must test a system in isolation, first write down all the ways it could fail, then write the code.** Each failure mode (bad input, boundary, concurrency, partial failure, permission denial, resource cap) becomes one test that fails before the code exists. -- A regression test must fail on the pre-fix code. Revert each guard of the fix and watch its test go red before you trust it. -- Never write tests that restate declarations (block/tool/provider config, registries, constants, schemas accepting valid input), mock every collaborator and assert `toHaveBeenCalledWith` on the mocks, check rendered text or class names, or test mocks and factories themselves. -- Never hand-roll a mock or test helper that `apps/sim/vitest.setup.ts` or `@sim/testing` already provides; a module mocked in a third file gets one central mock. `bun run check:test-patterns` enforces this. +- Never write unit tests after the code: a test written to describe existing code restates it and proves nothing. When a change needs proof, prove it end to end against the real boundary (real Postgres/Redis `*.integration.ts`, the running app over HTTP (`apps/sim/scripts/test-*-e2e.ts`), or the packaged desktop app), ending in a report artifact written to a caller-supplied `_REPORT_PATH` (`apps/sim/scripts/test-scim-e2e.ts` is the reference). +- Testing in isolation: first list the failure modes (bad input, boundary, concurrency, partial failure, permission denial, resource cap), then write one test per mode that fails before the code exists. A regression test must fail on the pre-fix code: revert each guard of the fix and watch its test go red. +- Never test declarations, mock every collaborator and assert on the mocks, check rendered text or class names, or hand-roll a mock that `apps/sim/vitest.setup.ts` or `@sim/testing` provides (`check:test-patterns`). -Use the `test-audit` skill whenever you write, change, review, or sweep tests — it holds the authoring gate, the junk patterns, and the retention bar. Test layers, file naming, and Vitest mechanics (global mocks, `@sim/testing`, performance rules) are in `.claude/rules/sim-testing.md`. +Use the `test-audit` skill whenever you write, change, or review tests; mechanics are in `.claude/rules/sim-testing.md`. ## Integrations -Build order: **Tools** → **Block** → **Icon** → optional **Trigger**, starting from the service's API docs. Use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`. Two rules the skills assume: - -- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`, alphabetically). -- **Type coercions go in `tools.config.params`** (runs at execution, after variable resolution), never in `tools.config.tool` (runs at serialization, where `Number()` destroys dynamic `` references). - -A surface that lists integrations to a viewer (a catalog, picker, or `@` menu) reads `useIntegrationAccess` (`hooks/use-integration-access.ts`): the one verdict across permission group, deployment allowlist, and deployment OAuth configuration, which fails closed until the viewer's policy loads. Never map over a raw catalog (`INTEGRATIONS`, `listIntegrations()`, `INTEGRATION_METADATA`) there; `check:integration-access` fails the build. - -Remaining block/tool/trigger rules: `.claude/rules/sim-integrations.md`. Canvas sentences: `apps/sim/blocks/AGENTS.md`. - -## Tables - -Table column types are registry entries in `apps/sim/lib/table/column-types/` — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. `Record` on `registry.ts` and `registry.server.ts` is a compile-time completeness gate: adding a type to the union errors until both entries exist. +Build order **Tools** → **Block** → **Icon** → optional **Trigger**, from the service's API docs, using `/add-integration` (or `/add-tools`, `/add-block`, `/add-trigger`). -Never add a `case 'sometype':` outside `column-types/`, except the one documented import-path switch (`coerceValue` in `lib/table/import.ts`), which every new type extends — a missing arm fails silently (a wrong `jsonbCast` breaks every filter on the column). If a consumer needs per-type knowledge, add a registry field. Use `/add-column-type` for the full procedure. +- **Tool IDs are `snake_case`** (`service_action`), registered in `tools/registry.ts`; blocks register alphabetically in `blocks/registry-maps.ts`. +- **Type coercions go in `tools.config.params`** (runs at execution), never `tools.config.tool` (runs at serialization, where `Number()` destroys `` references). +- A surface listing integrations to a viewer reads `useIntegrationAccess`, never a raw catalog (`check:integration-access`). +- Table column types are registry entries in `apps/sim/lib/table/column-types/`; never add a per-type `case` elsewhere except the one documented import-path switch (`coerceValue` in `lib/table/import.ts`), which every new type extends: a missed arm fails silently. Use `/add-column-type`. ## How your work is checked -Before declaring a change done, run the local gate from the repo root; CI runs these and fails on any of them: +Before declaring a change done, run the local gate from the repo root; CI fails on any of them: ```bash bun run lint # biome format + lint, autofixes (CI runs lint:check) @@ -150,4 +76,4 @@ git fetch origin staging # the block-registry check diffs against it bun run apps/sim/scripts/check-block-registry.ts origin/staging ``` -Author a `packages/db` schema change or migration with the `db-migrate` skill. CI also runs `bun run check:migrations` (it diffs against a base ref, `origin/staging` by default, so it is not in `check:audits`; run it when you touch `packages/db/migrations/**`), checks that `drizzle-kit generate` in `packages/db` produces no new migration, and runs a non-blocking `bun audit`. When an audit fails, its output and its script's header say what the rule protects; fix the code, never the check. Ratchet baselines (`scripts/*baseline.json`) only shrink: regenerate one with `--update` after removing violations; it refuses to admit new debt. The one exception is `check:tool-registry-boundary`, whose module-count baseline is re-recorded with `--update-baseline` when growth is deliberate (see its skill). +Author a `packages/db` schema change or migration with the `db-migrate` skill and run `bun run check:migrations` (not in `check:audits`). Ratchet baselines (`scripts/*baseline.json`) only shrink: regenerate one with `--update` after removing violations. The one exception is `check:tool-registry-boundary`, whose module-count baseline is re-recorded with `--update-baseline` when growth is deliberate (see the `tool-registry-boundary` skill). diff --git a/README.md b/README.md index 4ca3af46cd4..5bdd7432fcc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Ask DeepWiki - Set Up with Cursor + Set Up with Cursor

diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts new file mode 100644 index 00000000000..f7218cd2af6 --- /dev/null +++ b/apps/desktop/src/main/app-routes.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { settingsRoute } from '@/main/app-routes' + +describe('settingsRoute', () => { + it('opens the settings of the organization the user was last in', () => { + expect(settingsRoute('/o/org-1/search?q=x')).toBe('/o/org-1/settings/general') + }) + + it('keeps the desktop settings of the workspace the user was last in', () => { + expect(settingsRoute('/workspace/ws-1/home')).toBe('/workspace/ws-1/settings/desktop') + }) + + it.each([ + ['the workspace picker', '/workspace'], + ['the app entry', '/home'], + ['no saved route', undefined], + ['a route that only starts like an organization', '/oauth/o/org-1'], + ['an unsafe route', '//evil.example/o/org-1'], + ])('resolves settings through the web app from %s', (_label, lastRoute) => { + expect(settingsRoute(lastRoute)).toBe('/workspace?redirect=settings') + }) +}) diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts index abbf62bba33..22d0ad9f9e5 100644 --- a/apps/desktop/src/main/app-routes.ts +++ b/apps/desktop/src/main/app-routes.ts @@ -16,6 +16,12 @@ import { isSafeInternalPath } from '@/main/config' */ export const APP_ENTRY_ROUTE = '/home' +/** + * Settings for a viewer whose last route names no workspace or organization. The web + * app resolves it to their most recent workspace, or to their organization's settings. + */ +const SETTINGS_ENTRY_ROUTE = '/workspace?redirect=settings' + /** Workspace id from the last visited route, or null when it carries none. */ function workspaceIdFromRoute(lastRoute: string | undefined): string | null { if (isSafeInternalPath(lastRoute)) { @@ -27,6 +33,16 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null { return null } +function organizationIdFromRoute(lastRoute: string | undefined): string | null { + if (isSafeInternalPath(lastRoute)) { + const match = /^\/o\/([^/?#]+)/.exec(lastRoute) + if (match) { + return match[1] + } + } + return null +} + /** * Route for "New Chat": the home (chat) surface of the workspace the user was * last in, falling back to the app entry when the last route carries no @@ -38,10 +54,14 @@ export function newChatRoute(lastRoute: string | undefined): string { } /** - * Route for "Settings…": the Sim app's settings surface for the workspace the - * user was last in, falling back to the app entry. + * Route for "Settings…": the settings of the workspace or organization the user + * was last in. Never the app entry, which lands on a page without settings. */ export function settingsRoute(lastRoute: string | undefined): string { const workspaceId = workspaceIdFromRoute(lastRoute) - return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE + if (workspaceId) { + return `/workspace/${workspaceId}/settings/desktop` + } + const organizationId = organizationIdFromRoute(lastRoute) + return organizationId ? `/o/${organizationId}/settings/general` : SETTINGS_ENTRY_ROUTE } diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index b55193ce83b..6307ebb063d 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -1,9 +1,11 @@ import { get as httpGet } from 'node:http' +import type { BrowserWindow } from 'electron' import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) import { + createAuthFlow, createHandoffManager, type HandoffCallback, type HandoffCallbacks, @@ -161,6 +163,31 @@ describe('createHandoffManager', () => { }) }) +describe('login handoff landing', () => { + it('lands on the app entry after redeeming the login token', async () => { + const win = { + isDestroyed: () => false, + loadURL: vi.fn(async () => {}), + show: vi.fn(), + focus: vi.fn(), + webContents: { + getURL: () => 'https://sim.ai/login', + executeJavaScript: vi.fn(async () => 200), + }, + } + const flow = createAuthFlow({ + handoff: { consume: () => true } as unknown as ReturnType, + origin: () => 'https://sim.ai', + events: makeEvents(), + ensureMainWindow: async () => win as unknown as BrowserWindow, + }) + + await flow.handleCallback({ token: VALID_TOKEN, state: _VALID_STATE }) + + expect(win.loadURL).toHaveBeenCalledExactlyOnceWith('https://sim.ai/home') + }) +}) + describe('connect handoff account pinning', () => { it('keeps a source request correlated across the browser and native completion', async () => { const deps = makeDeps() diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index caeb3a4b612..40fd7e3586c 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import type { BrowserWindow } from 'electron' import { app } from 'electron' +import { APP_ENTRY_ROUTE } from '@/main/app-routes' import { showShellDialog } from '@/main/dialogs' import type { EventRecorder } from '@/main/observability' @@ -451,7 +452,7 @@ export interface AuthFlow { /** * Orchestrates the login handoff: opening the system browser, consuming the - * loopback callback, redeeming the token, and navigating to the workspace. A + * loopback callback, redeeming the token, and navigating to the app entry. A * failed or expired callback never leaves a partial session — the window lands * back on /login. */ @@ -521,7 +522,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { } deps.events.record('handoff_redeem_ok') try { - await win.loadURL(`${origin}/workspace`) + await win.loadURL(`${origin}${APP_ENTRY_ROUTE}`) } catch {} win.show() win.focus() diff --git a/apps/docs/EDITORIAL_GUIDE.md b/apps/docs/EDITORIAL_GUIDE.md index 60561bd4de5..e7fb832e5ce 100644 --- a/apps/docs/EDITORIAL_GUIDE.md +++ b/apps/docs/EDITORIAL_GUIDE.md @@ -60,7 +60,7 @@ A link replaces repeated detail only after the owner contains it. Keep a concise - Use a callout for a specific prerequisite or a non-obvious security, data-loss, or operational risk that deserves interruption. Use ordinary prose or a table for normal explanation. Keep important limits visible. - Use Markdown lists and tables for simple procedures and reference data. Use tabs for distinct configurations and Steps for substantial sequential procedures. - Integration table cells use ordinary word wrapping so short field names and types stay readable. Keep long tokens within the existing table scroll container; do not let them widen the page. -- On large integration references, keep action and trigger descriptions visible and fold their field tables into `ReferenceDetails`. Preserve headings inside each disclosure so direct links and browser search can reveal them. Setup requirements and consequential warnings stay outside the folded tables. +- On integration references, keep action and trigger descriptions visible and fold their field tables into `ReferenceDetails`, regardless of the number of fields. Preserve headings inside each disclosure so direct links and browser search can reveal them. Setup requirements and consequential warnings stay outside the folded tables. - Integration logos belong beside the page title, using the generated `integration` frontmatter. Keep the regular docs header actions and do not repeat the logo in the article body. - Match inline code to Sim Chat: normal-weight monospace text, tight padding, and the existing borderless filled surface tokens. Scope the treatment to prose code; preserve fenced examples, API route bars, schema controls, and selector labels. - Use the shared `TabsSelect` control for API response and request-example pickers, with accessible names that include the current value, checked radio semantics for single choices, and menus aligned to their triggers. Keep docs pickers nonmodal so they do not hide lazily hydrating siblings. Keep response headings free of inherited extra margins. Scope dependency filter styling to `data-object-search-input`, including schema popovers portaled to the body; focus stays inside the field, and touch fields retain the shared target and text sizes. diff --git a/apps/docs/components/docs/navigation.tsx b/apps/docs/components/docs/navigation.tsx index 73d5eb4f693..cd6b1594f6d 100644 --- a/apps/docs/components/docs/navigation.tsx +++ b/apps/docs/components/docs/navigation.tsx @@ -17,7 +17,7 @@ interface NavigationItemProps extends Omit { } const rowClass = - 'group flex min-h-[30px] w-full items-center gap-1.5 rounded-lg px-2 py-[5px] text-docs-nav text-[var(--docs-text)] transition-colors focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2' + 'group flex min-h-[30px] w-full items-center gap-1.5 rounded-lg px-2 py-[5px] text-left text-docs-nav text-[var(--docs-text)] transition-colors focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2' function containsPath(node: NavNode, pathname: string): boolean { return ( diff --git a/apps/docs/components/docs/table-of-contents.tsx b/apps/docs/components/docs/table-of-contents.tsx index ac590fd17b5..4f4b5289e3c 100644 --- a/apps/docs/components/docs/table-of-contents.tsx +++ b/apps/docs/components/docs/table-of-contents.tsx @@ -26,11 +26,11 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo if (details) details.open = false }} className={cn( - 'dark:-mx-2 block rounded-md pointer-coarse:py-3 text-docs-control leading-5 transition-colors focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 dark:px-2 dark:hover:bg-[var(--docs-hover)]', + 'dark:-mx-2 block rounded-md pointer-coarse:py-3 text-docs-control leading-5 transition-colors focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 dark:px-2', depth > 2 && 'pl-3 dark:pl-5', active === id - ? 'text-[var(--docs-toc-current)] dark:underline dark:underline-offset-[3px]' - : 'text-[var(--docs-muted)] hover:text-[var(--docs-heading)] dark:hover:text-[var(--docs-muted)]' + ? 'text-[var(--docs-toc-current)] underline underline-offset-[3px]' + : 'text-[var(--docs-muted)]' )} > {title} diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index cc4d3c332de..0165a855525 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -14,6 +14,19 @@ export function LucidIcon(props: LucidIconProps) { ) } +interface GaugeIconProps extends SVGProps {} + +export function GaugeIcon(props: GaugeIconProps) { + return ( + + + + ) +} + export function EnrichmentIcon(props: SVGProps) { return ( = { fireflies_v2: FirefliesIcon, fivetran: FivetranIcon, flint: FlintIcon, + flow: ApiIcon, function: CodeIcon, gamma: GammaIcon, + gauge: GaugeIcon, generic: WebhookIcon, generic_webhook: Webhook, github: GithubIcon, @@ -490,6 +493,7 @@ export const blockTypeToIconMap: Record = { intercom: IntercomIcon, intercom_v2: IntercomIcon, intune: IntuneIcon, + jama: ApiIcon, jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index 4cd441379c5..f0f98056185 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -168,6 +168,7 @@ sim credentials update [options] | `--service-account-json ` | No | Write-only provider service-account JSON configuration, including Oracle Database connection fields. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--provider-workspace-id ` | No | Provider workspace identifier, separate from the Sim workspace. | | `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 6d9396c5ba1..ab5fbae41b5 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -718,6 +718,7 @@ sim credentials update [options] | `--service-account-json ` | No | Write-only provider service-account JSON configuration, including Oracle Database connection fields. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--provider-workspace-id ` | No | Provider workspace identifier, separate from the Sim workspace. | | `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | @@ -5102,7 +5103,7 @@ sim selectors get [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Provider option ID to resolve with the same selector and declared context. | @@ -5124,7 +5125,7 @@ sim selectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx index 39e82af527b..f444991a7ed 100644 --- a/apps/docs/content/docs/cli/selectors.mdx +++ b/apps/docs/content/docs/cli/selectors.mdx @@ -25,7 +25,7 @@ sim selectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -49,7 +49,7 @@ sim selectors get [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Provider option ID to resolve with the same selector and declared context. | diff --git a/apps/docs/content/docs/integrations/apify.mdx b/apps/docs/content/docs/integrations/apify.mdx index d0dd8754a23..c0e6a7929d3 100644 --- a/apps/docs/content/docs/integrations/apify.mdx +++ b/apps/docs/content/docs/integrations/apify.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Apify](https://apify.com/) runs Actors for web scraping and browser automation. Run an Actor synchronously when it can finish within the synchronous endpoint’s five-minute limit. **Run Actor (Async)** starts a provider run and polls it within the same Sim step; it does not return immediately for detached processing. Its polling budget follows Sim’s default synchronous execution timeout. If that wait ends before the Actor finishes, use the returned run ID with **Get Run**, then retrieve dataset items after success. You can also run saved Actor tasks and fetch dataset items directly. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Run any Apify actor or saved task with custom input, fetch dataset items, and check run status. Supports both synchronous and asynchronous execution with automatic dataset fetching. @@ -22,6 +23,8 @@ Run any Apify actor or saved task with custom input, fetch dataset items, and ch Run an APIFY actor synchronously and get results (max 5 minutes) + + #### Input | Parameter | Type | Required | Description | @@ -42,10 +45,14 @@ Run an APIFY actor synchronously and get results (max 5 minutes) | `status` | string | Run status \(SUCCEEDED, FAILED, etc.\) | | `items` | array | Dataset items \(if completed\) | + + ### APIFY Run Actor (Async) Start an APIFY Actor run and poll it within the same workflow step until completion or the polling deadline + + #### Input | Parameter | Type | Required | Description | @@ -69,10 +76,14 @@ Start an APIFY Actor run and poll it within the same workflow step until complet | `datasetId` | string | Dataset ID containing results | | `items` | array | Dataset items \(if completed\) | + + ### APIFY Run Task Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes) + + #### Input | Parameter | Type | Required | Description | @@ -93,10 +104,14 @@ Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes) | `status` | string | Run status \(SUCCEEDED, FAILED, etc.\) | | `items` | array | Dataset items produced by the run | + + ### APIFY Get Dataset Items Retrieve items stored in an APIFY dataset + + #### Input | Parameter | Type | Required | Description | @@ -116,10 +131,14 @@ Retrieve items stored in an APIFY dataset | `items` | array | Items stored in the dataset | | `count` | number | Number of items returned | + + ### APIFY Get Run Get the status and details of an APIFY actor run + + #### Input | Parameter | Type | Required | Description | @@ -140,4 +159,6 @@ Get the status and details of an APIFY actor run | `keyValueStoreId` | string | Default key-value store ID for the run | | `stats` | json | Run statistics \(memory, CPU, duration\) | + + diff --git a/apps/docs/content/docs/integrations/arxiv.mdx b/apps/docs/content/docs/integrations/arxiv.mdx index a764268412d..548c01b97e7 100644 --- a/apps/docs/content/docs/integrations/arxiv.mdx +++ b/apps/docs/content/docs/integrations/arxiv.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [arXiv](https://arxiv.org/) hosts research papers. Search by keyword, author, title, or category, retrieve a paper’s metadata, or list papers by an author. This integration does not require an API key or OAuth connection. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Search arXiv papers, retrieve paper metadata, or list an author’s papers. No API key or OAuth connection is required. @@ -22,6 +23,8 @@ Search arXiv papers, retrieve paper metadata, or list an author’s papers. No A Search for academic papers on ArXiv by keywords, authors, titles, or other fields. + + #### Input | Parameter | Type | Required | Description | @@ -39,10 +42,14 @@ Search for academic papers on ArXiv by keywords, authors, titles, or other field | `papers` | json | Array of papers matching the search query | | `totalResults` | number | Total number of results found for the search query | + + ### ArXiv Get Paper Get detailed information about a specific ArXiv paper by its ID. + + #### Input | Parameter | Type | Required | Description | @@ -55,10 +62,14 @@ Get detailed information about a specific ArXiv paper by its ID. | --------- | ---- | ----------- | | `paper` | json | Detailed information about the requested ArXiv paper | + + ### ArXiv Get Author Papers Search for papers by a specific author on ArXiv. + + #### Input | Parameter | Type | Required | Description | @@ -73,4 +84,6 @@ Search for papers by a specific author on ArXiv. | `authorPapers` | json | Array of papers authored by the specified author | | `totalResults` | number | Total number of papers found for the author | + + diff --git a/apps/docs/content/docs/integrations/azure_devops.mdx b/apps/docs/content/docs/integrations/azure_devops.mdx index 37a22cf2d37..8b2bcc313c2 100644 --- a/apps/docs/content/docs/integrations/azure_devops.mdx +++ b/apps/docs/content/docs/integrations/azure_devops.mdx @@ -616,6 +616,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when an Azure DevOps build fails, is canceled, or partially succeeds + + #### Output | Parameter | Type | Description | @@ -634,13 +636,14 @@ Trigger workflow when an Azure DevOps build fails, is canceled, or partially suc | `finishTime` | string | Build finish time \(ISO 8601\) | | `buildUrl` | string | API URL for the build resource | - ---- + ### Azure DevOps Webhook (All Service Hook Events) Trigger on whichever service hook event types you configure in Azure DevOps. Sim does not filter deliveries for this trigger. + + #### Output | Parameter | Type | Description | @@ -655,13 +658,14 @@ Trigger on whichever service hook event types you configure in Azure DevOps. Sim | `message` | json | Short message object | | `detailedMessage` | json | Detailed message object | - ---- + ### Azure DevOps Work Item Created Trigger workflow when a work item is created in Azure DevOps + + #### Output | Parameter | Type | Description | @@ -679,3 +683,5 @@ Trigger workflow when a work item is created in Azure DevOps | `projectName` | string | Azure DevOps project name | | `workItemUrl` | string | API URL for the work item resource | + + diff --git a/apps/docs/content/docs/integrations/brandfetch.mdx b/apps/docs/content/docs/integrations/brandfetch.mdx index 513fabac904..780a61ea47c 100644 --- a/apps/docs/content/docs/integrations/brandfetch.mdx +++ b/apps/docs/content/docs/integrations/brandfetch.mdx @@ -67,6 +67,8 @@ Retrieve brand assets including logos, colors, fonts, and company info by domain Search for brands by name and find their domains and logos + + #### Input | Parameter | Type | Required | Description | @@ -85,4 +87,6 @@ Search for brands by name and find their domains and logos | ↳ `claimed` | boolean | Whether the brand profile is claimed | | ↳ `icon` | string | Brand icon URL | + + diff --git a/apps/docs/content/docs/integrations/calendly.mdx b/apps/docs/content/docs/integrations/calendly.mdx index 2466bc54149..59b04c7b283 100644 --- a/apps/docs/content/docs/integrations/calendly.mdx +++ b/apps/docs/content/docs/integrations/calendly.mdx @@ -935,6 +935,8 @@ Trigger workflow when someone submits a Calendly routing form Trigger workflow from any Calendly webhook event + + #### Configuration | Parameter | Type | Required | Description | @@ -951,3 +953,5 @@ Trigger workflow from any Calendly webhook event | `created_by` | string | URI of the Calendly user who created this webhook | | `payload` | object | Complete event payload \(structure varies by event type\) | + + diff --git a/apps/docs/content/docs/integrations/clay.mdx b/apps/docs/content/docs/integrations/clay.mdx index a4efa3caf13..92e603b5e2f 100644 --- a/apps/docs/content/docs/integrations/clay.mdx +++ b/apps/docs/content/docs/integrations/clay.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Clay](https://www.clay.com/) organizes and enriches data in tables. Use the Clay Populate action to send structured data from your Sim workflow to a Clay table through its webhook URL—for example, lead records or research results. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Send structured records to a Clay table through its webhook URL. @@ -22,6 +23,8 @@ Send structured records to a Clay table through its webhook URL. Send structured JSON data to a Clay table webhook and return its response and HTTP metadata. + + #### Input | Parameter | Type | Required | Description | @@ -42,4 +45,6 @@ Send structured JSON data to a Clay table webhook and return its response and HT | ↳ `timestamp` | string | ISO timestamp recorded when the webhook response was processed | | ↳ `contentType` | string | Content type of the response | + + diff --git a/apps/docs/content/docs/integrations/datagma.mdx b/apps/docs/content/docs/integrations/datagma.mdx index df9fdd71d17..3d3158c2659 100644 --- a/apps/docs/content/docs/integrations/datagma.mdx +++ b/apps/docs/content/docs/integrations/datagma.mdx @@ -25,6 +25,8 @@ Integrate Datagma to find verified work emails from a name and company, enrich p Find a verified work email from a person's full name and company. Uses 1 credit when a verified email is found. + + #### Input | Parameter | Type | Required | Description | @@ -47,6 +49,8 @@ Find a verified work email from a person's full name and company. Uses 1 credit | `smtpCheck` | boolean | Whether SMTP validation succeeded | | `catchAll` | boolean | Whether the domain is catch-all | + + ### Datagma Enrich Person Enrich a person's profile using their email, LinkedIn URL, or full name and company. Returns job title, company, location, and social data. Uses 2 credits per match; add 30 credits when a phone number is found. @@ -92,6 +96,8 @@ Enrich a person's profile using their email, LinkedIn URL, or full name and comp Enrich a company profile using a domain, company name, or SIREN number (France). Returns size, industry, revenue, and description. Uses 2 credits per match. + + #### Input | Parameter | Type | Required | Description | @@ -115,10 +121,14 @@ Enrich a company profile using a domain, company name, or SIREN number (France). | `revenueRange` | string | Estimated annual revenue range | | `headquarters` | string | Headquarters location | + + ### Datagma Find Phone Find a mobile phone number from a person's LinkedIn URL. Optionally supply an email to improve match accuracy. Uses 30 credits when a number is found. + + #### Input | Parameter | Type | Required | Description | @@ -136,10 +146,14 @@ Find a mobile phone number from a person's LinkedIn URL. Optionally supply an em | `countryCode` | string | Country code prefix \(e.g., +1\) | | `isWhatsapp` | boolean | Whether the number is linked to WhatsApp | + + ### Datagma Get Credits Check remaining credit balance on a Datagma account. Free — no credits consumed. + + #### Input | Parameter | Type | Required | Description | @@ -152,4 +166,6 @@ Check remaining credit balance on a Datagma account. Free — no credits consume | --------- | ---- | ----------- | | `credits` | number | Remaining Datagma credits | + + diff --git a/apps/docs/content/docs/integrations/deployments.mdx b/apps/docs/content/docs/integrations/deployments.mdx index 1b5a16e885b..714b7b8ed20 100644 --- a/apps/docs/content/docs/integrations/deployments.mdx +++ b/apps/docs/content/docs/integrations/deployments.mdx @@ -6,6 +6,8 @@ integration: color: "#0C0C0C" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use the Deployments block to manage other workflows in the current workspace. Deploy creates a new version; Promote Version to Live makes an existing version live without creating another. Undeploy takes a workflow offline and removes its triggers, webhooks, and schedules. @@ -13,7 +15,6 @@ Use the Deployments block to manage other workflows in the current workspace. De For the deployment UI and execution model, see [Workflow Deployment](/workflows/deployment). {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Deploy, undeploy, and roll back workflows in the current workspace. Promote a previous deployment version to live, list every version, or fetch the deployed workflow state for a specific version. @@ -24,6 +25,8 @@ Deploy, undeploy, and roll back workflows in the current workspace. Promote a pr Deploy a workflow’s current draft state, creating a new deployment version and making it live for API execution. Requires admin permission on the workflow’s workspace. + + #### Input | Parameter | Type | Required | Description | @@ -42,10 +45,14 @@ Deploy a workflow’s current draft state, creating a new deployment version and | `version` | number | The deployment version that is now active | | `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule sync still in progress\) | + + ### Undeploy Workflow Take a deployed workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Requires admin permission on the workflow’s workspace. + + #### Input | Parameter | Type | Required | Description | @@ -61,10 +68,14 @@ Take a deployed workflow offline. API execution stops and schedules, webhooks, a | `deployedAt` | string | Always null after an undeploy | | `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule cleanup still in progress\) | + + ### Promote Version to Live Make a specific deployment version the live one without creating a new version — the same operation as Promote to live in the deploy modal. Useful for rolling back to a known-good version. Also works on an undeployed workflow: it re-deploys the workflow live at that version. Requires admin permission on the workflow’s workspace. + + #### Input | Parameter | Type | Required | Description | @@ -82,10 +93,14 @@ Make a specific deployment version the live one without creating a new version | `version` | number | The deployment version that is now live | | `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule sync still in progress\) | + + ### List Deployment Versions List every deployment version of a workflow, newest first, including which version is currently live. + + #### Input | Parameter | Type | Required | Description | @@ -99,10 +114,14 @@ List every deployment version of a workflow, newest first, including which versi | `workflowId` | string | ID of the workflow | | `versions` | array | Deployment versions, newest first \(id, version, name, description, isActive, createdAt, createdBy, deployedByName\) | + + ### Get Deployment Version Fetch a single deployment version of a workflow, including its metadata and the full workflow state snapshot that was deployed. + + #### Input | Parameter | Type | Required | Description | @@ -122,4 +141,6 @@ Fetch a single deployment version of a workflow, including its metadata and the | `createdAt` | string | When this version was deployed \(ISO 8601\) | | `deployedState` | json | The full workflow state snapshot \(blocks, edges, loops, parallels, variables\) | + + diff --git a/apps/docs/content/docs/integrations/dspy.mdx b/apps/docs/content/docs/integrations/dspy.mdx index 297a1b9edb6..67588bb807a 100644 --- a/apps/docs/content/docs/integrations/dspy.mdx +++ b/apps/docs/content/docs/integrations/dspy.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [DSPy](https://github.com/stanford-oval/dspy) to call a self-hosted server that exposes your DSPy programs. Configure the server URL, authentication, input fields, and context expected by that endpoint. @@ -13,7 +15,6 @@ Use [DSPy](https://github.com/stanford-oval/dspy) to call a self-hosted server t The block supports Predict, Chain of Thought, and ReAct operations. The behavior of each call depends on the DSPy program and model you deploy; Sim does not host the program for you. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming—not prompting—language models. @@ -24,6 +25,8 @@ Integrate with your self-hosted DSPy programs for LLM-powered predictions. Suppo Run a prediction using a self-hosted DSPy program endpoint + + #### Input | Parameter | Type | Required | Description | @@ -45,10 +48,14 @@ Run a prediction using a self-hosted DSPy program endpoint | `status` | string | Response status from the DSPy server \(success or error\) | | `rawOutput` | json | The complete raw output from the DSPy program \(result.toDict\(\)\) | + + ### DSPy Chain of Thought Run a Chain of Thought prediction using a self-hosted DSPy ChainOfThought program endpoint + + #### Input | Parameter | Type | Required | Description | @@ -68,10 +75,14 @@ Run a Chain of Thought prediction using a self-hosted DSPy ChainOfThought progra | `status` | string | Response status from the DSPy server \(success or error\) | | `rawOutput` | json | The complete raw output from the DSPy program \(result.toDict\(\)\) | + + ### DSPy ReAct Run a ReAct agent using a self-hosted DSPy ReAct program endpoint for multi-step reasoning and action + + #### Input | Parameter | Type | Required | Description | @@ -97,4 +108,6 @@ Run a ReAct agent using a self-hosted DSPy ReAct program endpoint for multi-step | `status` | string | Response status from the DSPy server \(success or error\) | | `rawOutput` | json | The complete raw output from the DSPy program \(result.toDict\(\)\) | + + diff --git a/apps/docs/content/docs/integrations/embeddings.mdx b/apps/docs/content/docs/integrations/embeddings.mdx index 45b8e1ede80..db50063f541 100644 --- a/apps/docs/content/docs/integrations/embeddings.mdx +++ b/apps/docs/content/docs/integrations/embeddings.mdx @@ -6,6 +6,8 @@ integration: color: "#7B4DFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} An embedding represents text as a numeric vector. Compare vectors to search by meaning, group related text, or find near-duplicates. @@ -31,7 +33,6 @@ Ollama has no task-type or dimension control in this block. Sim uses the model's Knowledge bases embed separately. Each base fixes an embedding model and vector width at creation from the supported knowledge base catalog. Use this block when a workflow needs to generate vectors itself. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, OpenRouter, Google Gemini, Cohere, and Mistral embedding models, plus embedding models on a self-hosted Ollama. @@ -42,6 +43,8 @@ Turn text into embedding vectors for semantic search, clustering, and similarity Generate embeddings from text using OpenAI's embedding models + + #### Input | Parameter | Type | Required | Description | @@ -64,10 +67,14 @@ Generate embeddings from text using OpenAI's embedding models | ↳ `prompt_tokens` | number | Tokens in the input | | ↳ `total_tokens` | number | Total tokens billed | + + ### OpenRouter Embeddings Generate embeddings through OpenRouter + + #### Input | Parameter | Type | Required | Description | @@ -90,10 +97,14 @@ Generate embeddings through OpenRouter | ↳ `prompt_tokens` | number | Tokens in the input | | ↳ `total_tokens` | number | Total tokens billed | + + ### Gemini Embeddings Generate embeddings from text using Google's Gemini embedding models + + #### Input | Parameter | Type | Required | Description | @@ -116,10 +127,14 @@ Generate embeddings from text using Google's Gemini embedding models | ↳ `prompt_tokens` | number | Tokens in the input | | ↳ `total_tokens` | number | Total tokens billed | + + ### Cohere Embeddings Generate embeddings from text using Cohere's embedding models + + #### Input | Parameter | Type | Required | Description | @@ -142,10 +157,14 @@ Generate embeddings from text using Cohere's embedding models | ↳ `prompt_tokens` | number | Tokens in the input | | ↳ `total_tokens` | number | Total tokens billed | + + ### Mistral Embeddings Generate embeddings from text using Mistral's embedding models + + #### Input | Parameter | Type | Required | Description | @@ -168,10 +187,14 @@ Generate embeddings from text using Mistral's embedding models | ↳ `prompt_tokens` | number | Tokens in the input | | ↳ `total_tokens` | number | Total tokens billed | + + ### Ollama Embeddings Generate embeddings on a self-hosted Ollama server + + #### Input | Parameter | Type | Required | Description | @@ -191,4 +214,6 @@ Generate embeddings on a self-hosted Ollama server | ↳ `prompt_tokens` | number | Tokens in the input | | ↳ `total_tokens` | number | Total tokens billed | + + diff --git a/apps/docs/content/docs/integrations/enrichment.mdx b/apps/docs/content/docs/integrations/enrichment.mdx index fd851ee233a..2d197d98810 100644 --- a/apps/docs/content/docs/integrations/enrichment.mdx +++ b/apps/docs/content/docs/integrations/enrichment.mdx @@ -6,6 +6,8 @@ integration: color: "#9333EA" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use the built-in Data Enrichment block to fill missing fields such as work email, phone number, company domain, and company information. Select a registered enrichment and map the record's input fields; Sim uses the same ordered provider cascade as table enrichments until a provider returns a match. @@ -13,7 +15,6 @@ Use the built-in Data Enrichment block to fill missing fields such as work email Read the result to determine whether a match was found and which provider supplied it. For example, map a person's name and company into **Work Email** rather than calling each provider separately. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments. @@ -24,6 +25,8 @@ Run a Sim enrichment to look up data — work email, phone number, company domai Run a Sim enrichment (e.g. Work Email, Phone Number) and return its outputs + + #### Input | Parameter | Type | Required | Description | @@ -38,4 +41,6 @@ Run a Sim enrichment (e.g. Work Email, Phone Number) and return its outputs | `matched` | boolean | Whether the enrichment found a result | | `provider` | string | Provider whose result was returned \(e.g. "Hunter", "People Data Labs"\) | + + diff --git a/apps/docs/content/docs/integrations/enrow.mdx b/apps/docs/content/docs/integrations/enrow.mdx index 837fe2f0ad5..fd537f4af1b 100644 --- a/apps/docs/content/docs/integrations/enrow.mdx +++ b/apps/docs/content/docs/integrations/enrow.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Enrow](https://enrow.io/) to find a professional email from a person's full name and company, or check an existing email's deliverability. Inspect the returned verification result before using the address downstream. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Find verified B2B email addresses from a full name and company, or verify the deliverability of an existing email. Inspect the returned qualification before using an email address. @@ -22,6 +23,8 @@ Find verified B2B email addresses from a full name and company, or verify the de Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://docs.enrow.io/api-reference/email-finder/find-single) + + #### Input | Parameter | Type | Required | Description | @@ -44,10 +47,14 @@ Find a verified B2B email address from a full name and company domain or name. U | `company_name` | string | Company name associated with the result | | `company_domain` | string | Company domain associated with the result | + + ### Enrow Verify Email Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://docs.enrow.io/api-reference/email-verifier/verify-single) + + #### Input | Parameter | Type | Required | Description | @@ -63,4 +70,6 @@ Verify the deliverability of an email address using the Enrow async verifier. Su | `email` | string | Email address found or verified | | `qualification` | string | Enrow quality result: "valid" or "invalid" | + + diff --git a/apps/docs/content/docs/integrations/exa.mdx b/apps/docs/content/docs/integrations/exa.mdx index 88f75e710fe..0e5c657fc05 100644 --- a/apps/docs/content/docs/integrations/exa.mdx +++ b/apps/docs/content/docs/integrations/exa.mdx @@ -172,6 +172,8 @@ Find webpages similar to a given URL using Exa AI. Deprecated by Exa in favor of Get an AI-generated answer to a question with citations from the web using Exa AI. + + #### Input | Parameter | Type | Required | Description | @@ -195,10 +197,14 @@ Get an AI-generated answer to a question with citations from the web using Exa A | ↳ `publishedDate` | string | Publication date of the cited source | | `requestId` | string | Exa request identifier, useful for support | + + ### Exa Agent Run a deep research task with Exa Agent. Handles multi-step list building, enrichment, and research, returning a written answer with field-level citations and optional structured output. + + #### Input | Parameter | Type | Required | Description | @@ -222,4 +228,6 @@ Run a deep research task with Exa Agent. Handles multi-step list building, enric | `grounding` | json | Field-level citations backing the agent output | | `research` | array | The agent answer in the shape the retired Research operation emitted, so workflows that reference it keep resolving | + + diff --git a/apps/docs/content/docs/integrations/extend.mdx b/apps/docs/content/docs/integrations/extend.mdx index af47769076c..cb54644bdcb 100644 --- a/apps/docs/content/docs/integrations/extend.mdx +++ b/apps/docs/content/docs/integrations/extend.mdx @@ -6,12 +6,13 @@ integration: color: "#000000" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Extend](https://www.extend.ai/) to parse a document supplied as a file or file reference. The result includes structured chunks and block-level elements, the page count, and credits consumed. Pass those outputs to later steps for extraction or document review. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Parse and extract structured content from documents or file references. @@ -22,6 +23,8 @@ Parse and extract structured content from documents or file references. Parse and extract content from documents using Extend AI + + #### Input | Parameter | Type | Required | Description | @@ -43,4 +46,6 @@ Parse and extract content from documents using Extend AI | `pageCount` | number | Number of pages processed | | `creditsUsed` | number | API credits consumed | + + diff --git a/apps/docs/content/docs/integrations/fathom.mdx b/apps/docs/content/docs/integrations/fathom.mdx index 153f55f40c7..2e18443faa4 100644 --- a/apps/docs/content/docs/integrations/fathom.mdx +++ b/apps/docs/content/docs/integrations/fathom.mdx @@ -123,6 +123,8 @@ List recent meetings recorded by the user or shared to their team. List meeting types configured in your Fathom organization. + + #### Input | Parameter | Type | Required | Description | @@ -140,10 +142,14 @@ List meeting types configured in your Fathom organization. | ↳ `created_at` | string | Date the meeting type was created | | `next_cursor` | string | Pagination cursor for next page | + + ### Fathom Get Summary Get the call summary for a specific meeting recording. + + #### Input | Parameter | Type | Required | Description | @@ -158,10 +164,14 @@ Get the call summary for a specific meeting recording. | `template_name` | string | Name of the summary template used | | `markdown_formatted` | string | Markdown-formatted summary text | + + ### Fathom Get Transcript Get the full transcript for a specific meeting recording. + + #### Input | Parameter | Type | Required | Description | @@ -180,10 +190,14 @@ Get the full transcript for a specific meeting recording. | ↳ `text` | string | Transcript text | | ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) | + + ### Fathom List Team Members List team members in your Fathom organization. + + #### Input | Parameter | Type | Required | Description | @@ -202,10 +216,14 @@ List team members in your Fathom organization. | ↳ `created_at` | string | Date the member was added | | `next_cursor` | string | Pagination cursor for next page | + + ### Fathom List Teams List teams in your Fathom organization. + + #### Input | Parameter | Type | Required | Description | @@ -222,6 +240,8 @@ List teams in your Fathom organization. | ↳ `created_at` | string | Date the team was created | | `next_cursor` | string | Pagination cursor for next page | + + ## Triggers diff --git a/apps/docs/content/docs/integrations/firecrawl.mdx b/apps/docs/content/docs/integrations/firecrawl.mdx index 6ad51a2ede1..cbd6dd32e40 100644 --- a/apps/docs/content/docs/integrations/firecrawl.mdx +++ b/apps/docs/content/docs/integrations/firecrawl.mdx @@ -454,4 +454,38 @@ Retrieve the remaining and allocated Firecrawl credits for the team. +### Firecrawl Enrich + +Find work emails, phone numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company, name-based profile, and single-part-name contact lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Firecrawl API key | +| `enrichmentType` | string | Yes | Lookup type: work_email, phone, linkedin, company, or company_domain | +| `fullName` | string | No | Full name of the person | +| `companyDomain` | string | No | Company website domain | +| `companyName` | string | No | Company name when the domain is unknown | +| `linkedinUrl` | string | No | LinkedIn profile URL for work email or mobile lookup | +| `email` | string | No | Email address for reverse LinkedIn profile lookup | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `email` | string | Work email, when found | +| `emailVerified` | boolean | True only when FullEnrich reports DELIVERABLE; catch-all and high-probability addresses remain unverified | +| `phone` | string | Phone number, preferring mobile when available | +| `linkedinUrl` | string | LinkedIn profile URL, when found | +| `domain` | string | Company website domain, when found | +| `employeeCount` | string | Company headcount or size range | +| `description` | string | Company description | +| `creditsUsed` | number | Firecrawl credits charged for this lookup | +| `statusCode` | number | Provider or HTTP response status code | + + + diff --git a/apps/docs/content/docs/integrations/fireflies.mdx b/apps/docs/content/docs/integrations/fireflies.mdx index b60330507be..9540c7072c6 100644 --- a/apps/docs/content/docs/integrations/fireflies.mdx +++ b/apps/docs/content/docs/integrations/fireflies.mdx @@ -297,6 +297,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when a Fireflies meeting transcription is complete + + #### Configuration | Parameter | Type | Required | Description | @@ -312,3 +314,5 @@ Trigger workflow when a Fireflies meeting transcription is complete | `clientReferenceId` | string | Custom reference ID if set during upload | | `timestamp` | number | Unix timestamp in milliseconds when the event was fired \(V2 webhooks\) | + + diff --git a/apps/docs/content/docs/integrations/flint.mdx b/apps/docs/content/docs/integrations/flint.mdx index 06c4fa695c8..48f581b7553 100644 --- a/apps/docs/content/docs/integrations/flint.mdx +++ b/apps/docs/content/docs/integrations/flint.mdx @@ -6,12 +6,13 @@ integration: color: "#F6F54F" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Flint](https://www.flint.com/) in Sim to start site changes from a natural-language prompt, generate up to 10 pages from a template, and check task status. Create an API key in your Flint team settings. Leave publishing off when changes need review, then use **Get Task** to collect preview URLs and the list of created, modified, or deleted pages. Poll the task until it finishes and inspect its error if it fails. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Create background agent tasks that modify your Flint sites from natural-language prompts, generate batches of pages from a template, and check task status and results. @@ -22,6 +23,8 @@ Create background agent tasks that modify your Flint sites from natural-language Start a background Flint agent task that modifies a site from a natural-language prompt. + + #### Input | Parameter | Type | Required | Description | @@ -40,10 +43,14 @@ Start a background Flint agent task that modifies a site from a natural-language | `status` | string | Initial task status \(running\) | | `createdAt` | string | ISO 8601 timestamp when the task was created | + + ### Flint Generate Pages Start a background Flint agent task that generates up to 10 pages from a template page. + + #### Input | Parameter | Type | Required | Description | @@ -63,10 +70,14 @@ Start a background Flint agent task that generates up to 10 pages from a templat | `status` | string | Initial task status \(running\) | | `createdAt` | string | ISO 8601 timestamp when the task was created | + + ### Flint Get Task Get the status and results of a background Flint agent task. + + #### Input | Parameter | Type | Required | Description | @@ -85,4 +96,6 @@ Get the status and results of a background Flint agent task. | `pagesDeleted` | array | Pages deleted by the task \(populated when completed\) | | `errorMessage` | string | Error message when the task failed | + + diff --git a/apps/docs/content/docs/integrations/flow.mdx b/apps/docs/content/docs/integrations/flow.mdx new file mode 100644 index 00000000000..9fef14d810c --- /dev/null +++ b/apps/docs/content/docs/integrations/flow.mdx @@ -0,0 +1,567 @@ +--- +title: Flow Engineering +description: Manage engineering requirements and traceability +integration: + type: "flow" + color: "#2448C7" +--- + +import { ReferenceDetails } from "@/components/ui/reference-details" + + +{/* MANUAL-CONTENT-START:intro */} +[Flow Engineering](https://www.flowengineering.com/) connects requirements, systems, tests, and their relationships. Use the Flow block to search current project data, read values and history, manage discussions, and propose changes on a working branch. + +## Connect an account + +In Flow, open **Settings → API & MCP**, generate a personal API key, and copy it. Create a connection from the block's **Flow Account** field with: + +- **API key:** The complete key, including its `brd_` prefix. +- **API URL:** The URL from Flow's API settings. The default is `https://backend.branch.flowengineering.com`. The `/customer-api` suffix opens the API documentation and is removed from requests. +- **Workspace identifier:** The final segment of the MCP URL. For `https://backend.branch.flowengineering.com/mcp/your-workspace`, enter `your-workspace`. + +The key uses its owner's current Flow permissions. It does not grant additional project access or bypass read-only roles. Revoke or replace it in Flow when needed. This integration uses the current customer API, not the older refresh-token API under `api.flowengineering.com/rest/v1`. + +## Work with project data + +Select a project, then use **Get Data Model** to discover its categories and field keys. Requirements and tests are entities in those categories; names and custom fields can vary by project. Use the returned IDs rather than display names. + +**Search Entities** returns up to 100 compact matches by name, field value, or exact Flow/Human ID. Use **Get Entities** to read their full values and relationships. Search reflects the selected branch and may have incomplete text results while Flow's index warms. + +**List Entities** reads one page. Pass `nextPageStart` as **Cursor** and continue while `moreAvailable` is true, including after an empty page. **Filter Entities** and **Get Entity History** instead return `nextCursor`; continue until it is null. No operation automatically downloads every page. + +## Make changes + +Omitting **Branch** reads or writes Base (`master`). Use **Create Branch** and select its returned ID to prepare changes separately. Flow's project permissions and branch protections still apply; the integration does not request the admin bypass option. + +**Create Entities** and **Set Values** accept up to 100 entries per request. Each batch is atomic, but separate requests are separate transactions. Creates and other mutations are not automatically retried. After a timeout, read back the affected data before repeating a write. + +To reply to a discussion, pass the original comment ID as **Entity ID**, even when replying to a later reply. Use the same branch for the original thread and its replies. Resolve a thread by updating the original comment. + +See [Flow's API conventions](https://docs.flowengineering.com/api/conventions) for branch and pagination behavior, and [its API reference](https://docs.flowengineering.com/api/reference) for supported field values and filters. +{/* MANUAL-CONTENT-END */} + +## Use the block [#usage-instructions] + +Read and update Flow Engineering projects, requirements, tests, and other entities through its customer API. Search current project data, inspect categories and relationships, manage comments, and make controlled changes on a working branch using your connected account permissions. + +## Actions + +### Flow List Projects + +List projects accessible to the connected Flow user. The API returns up to the requested limit without a continuation cursor. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `limit` | number | No | Maximum results in this page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | array | List Projects result | + + + +### Flow Get Project + +Read a Flow project by its UUID. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | json | Get Project result | + + + +### Flow Get Data Model + +Read the project categories and field definitions before interpreting values or writing custom fields. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `categories` | array | Get Data Model result | + + + +### Flow List Branches + +List the Flow project branches and their review and protection state. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `branches` | array | List Branches result | + + + +### Flow Create Branch + +Create a working branch before modifying requirements, tests, or other project entities. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchName` | string | No | Name of the new branch | +| `parentId` | string | No | Parent branch ID; omit for Base | +| `description` | string | No | Description of the new branch | +| `isVariant` | boolean | No | Create a variant branch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Created branch ID | +| `number` | number | Assigned branch number | + + + +### Flow Search Entities + +Search live Flow entities by name, supported field values, or exact Flow/Human ID. Returns at most 100 compact matches; fetch entities for full values. Text results may be unavailable while the provider index warms. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `entityType` | string | No | Restrict to this category ID from Get Data Model | +| `query` | string | Yes | Search names and supported field values, or an exact Flow/Human ID; at most 1024 characters | +| `limit` | number | No | Maximum results in this page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `matches` | array | Search Entities result | + + + +### Flow List Entities + +Read one page of live Flow entities. Continue with nextPageStart while moreAvailable is true, including after an empty page. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `entityType` | string | No | Restrict to this category ID from Get Data Model | +| `cursor` | string | No | Opaque continuation cursor from the previous page | +| `limit` | number | No | Maximum results in this page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entities` | array | Entity states on this page | +| `nextPageStart` | string | Opaque cursor for the next page | +| `moreAvailable` | boolean | Continue even after empty pages while true | + + + +### Flow Get Entities + +Read the full values, relationships, and timestamps of up to 100 Flow entities in one project and branch. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `entityIds` | string | Yes | Comma-separated entity IDs; up to 100 per read | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entities` | array | Get Entities result | + + + +### Flow Create Entity + +Create an entity in a category on the selected branch. Branch protection and the connected user permissions remain enforced. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `entityType` | string | Yes | Category ID returned by Get Data Model | +| `name` | string | Yes | Entity name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entity` | json | Create Entity result | + + + +### Flow Create Entities + +Create 1–100 entities atomically on a single branch. A timed-out request may already have committed; inspect the branch before retrying. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `entities` | json | Yes | Array of 1–100 entities: \[\{entityType, name, id?\}\]. IDs may be omitted for server generation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entities` | array | Create Entities result | + + + +### Flow Set Value + +Set one field on a Flow entity using a key from the data model and a JSON value of the documented field type. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `fieldKey` | string | Yes | Stored field key returned by Get Data Model | +| `value` | string | Yes | JSON-encoded field value matching the data model. Use "text" for a string, 123 for a number, true or false for a boolean, an array or object, or null to clear the field. Use "" for an empty string. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entity` | json | Set Value result | + + + +### Flow Set Values + +Set 1–100 field values atomically on one branch. Returns each affected entity once in first-touched order. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `updates` | json | Yes | Array of 1–100 field updates: \[\{entityId, key, value\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entities` | array | Set Values result | + + + +### Flow Delete Entity + +Delete an entity on the selected branch. Branch protection is enforced. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities | +| `branchId` | string | No | Branch ID; omit or use master for Base | + +#### Output + +No output fields. + + + +### Flow Restore Entity + +Restore a deleted entity with its prior fields and links. Restoring a child does not restore its deleted parent. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities | +| `branchId` | string | No | Branch ID; omit or use master for Base | + +#### Output + +No output fields. + + + +### Flow Add Link + +Add a downstream relationship between Flow entities on the selected branch. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `destinationId` | string | Yes | ID of the downstream entity to link or unlink | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entity` | json | Add Link result | + + + +### Flow Remove Link + +Remove a downstream relationship between Flow entities on the selected branch. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `destinationId` | string | Yes | ID of the downstream entity to link or unlink | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entity` | json | Remove Link result | + + + +### Flow List Comments + +Read direct comment threads for an entity or direct replies to the original comment ID. Keep the same branch when following replies. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `includeResolved` | boolean | No | Include resolved comments | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comments` | array | List Comments result | + + + +### Flow Get Comment + +Read a comment using its ID and its returned entity_id as the parent path. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies | +| `commentId` | string | Yes | Comment ID; entityId must be the comment's returned entity_id | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comment` | json | Get Comment result | + + + +### Flow Create Comment + +Start a thread on an entity or reply using the original comment ID as entityId. Set the branch explicitly; replies do not inherit it. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `text` | string | Yes | Plain-text comment body | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comment` | json | Create Comment result | + + + +### Flow Update Comment + +Edit comment text or resolve/reopen its thread. Use the original comment to change the thread status. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies | +| `commentId` | string | Yes | Comment ID; entityId must be the comment's returned entity_id | +| `text` | string | No | Replacement plain-text comment body | +| `resolved` | boolean | No | Resolve the thread with true or reopen it with false | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comment` | json | Update Comment result | + + + +### Flow Delete Comment + +Delete a comment authored by the connected user. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies | +| `commentId` | string | Yes | Comment ID; entityId must be the comment's returned entity_id | + +#### Output + +No output fields. + + + +### Flow Filter Entities + +Read one page of IDs matching typed field conditions and AND/OR groups. Continue until nextCursor is null, including after empty pages; results reflect current state. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `filter` | json | Yes | Filter object: \{kind:"type",operation:"overlaps",values:\[categoryId\]\}, \{kind:"field",category_id,field_key,operation:"contains",value\}, or \{kind:"group",operator:"and"\|"or",conditions:\[...\]\}. Use data-model IDs and field-compatible operators; at most 50 nodes and 5 group levels | +| `cursor` | string | No | Opaque continuation cursor from the previous page | +| `limit` | number | No | Maximum results in this page | +| `timezone` | string | No | IANA timezone for date filters; defaults to UTC | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entityIds` | array | Matching entity IDs; fetch entities to read values | +| `nextCursor` | string | Continue until null, even after an empty page | + + + +### Flow Get Entity History + +Read a page of change history for up to 500 entities. Keep project, branch, and entity IDs fixed while paging; restart if a rebase expires the cursor. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Project UUID returned by List Projects | +| `branchId` | string | No | Branch ID; omit or use master for Base | +| `entityIds` | string | Yes | Comma-separated entity IDs; up to 500 per history page | +| `cursor` | string | No | Opaque continuation cursor from the previous page | +| `limit` | number | No | Maximum results in this page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entries` | array | Entity change history page | +| `nextCursor` | string | Continue until null | + + + + diff --git a/apps/docs/content/docs/integrations/gamma.mdx b/apps/docs/content/docs/integrations/gamma.mdx index aa01e68d077..e47cbfaa8b4 100644 --- a/apps/docs/content/docs/integrations/gamma.mdx +++ b/apps/docs/content/docs/integrations/gamma.mdx @@ -6,12 +6,13 @@ integration: color: "#002253" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Gamma](https://gamma.app/) to generate presentations, documents, webpages, or social posts from text or an existing template. Poll Check Status for asynchronous jobs and use the returned Gamma URL when generation completes. Themes and folders control styling and organization. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Generate presentations, documents, webpages, and social posts from text, create from templates, check generation status, and browse themes and folders. @@ -22,6 +23,8 @@ Generate presentations, documents, webpages, and social posts from text, create Generate a new Gamma presentation, document, webpage, or social post from text input. + + #### Input | Parameter | Type | Required | Description | @@ -51,10 +54,14 @@ Generate a new Gamma presentation, document, webpage, or social post from text i | --------- | ---- | ----------- | | `generationId` | string | The ID of the generation job. Use with Check Status to poll for completion. | + + ### Gamma Generate from Template Generate a new Gamma by adapting an existing template with a prompt. + + #### Input | Parameter | Type | Required | Description | @@ -74,10 +81,14 @@ Generate a new Gamma by adapting an existing template with a prompt. | --------- | ---- | ----------- | | `generationId` | string | The ID of the generation job. Use with Check Status to poll for completion. | + + ### Gamma Check Status Check the status of a Gamma generation job. Returns the gamma URL when completed, or error details if failed. + + #### Input | Parameter | Type | Required | Description | @@ -99,10 +110,14 @@ Check the status of a Gamma generation job. Returns the gamma URL when completed | ↳ `message` | string | Human-readable error message | | ↳ `statusCode` | number | HTTP status code of the error | + + ### Gamma List Themes List available themes in your Gamma workspace. Returns theme IDs, names, and keywords for styling. + + #### Input | Parameter | Type | Required | Description | @@ -125,10 +140,14 @@ List available themes in your Gamma workspace. Returns theme IDs, names, and key | `hasMore` | boolean | Whether more results are available on the next page | | `nextCursor` | string | Pagination cursor to pass as the after parameter for the next page | + + ### Gamma List Folders List available folders in your Gamma workspace. Returns folder IDs and names for organizing generated content. + + #### Input | Parameter | Type | Required | Description | @@ -148,4 +167,6 @@ List available folders in your Gamma workspace. Returns folder IDs and names for | `hasMore` | boolean | Whether more results are available on the next page | | `nextCursor` | string | Pagination cursor to pass as the after parameter for the next page | + + diff --git a/apps/docs/content/docs/integrations/gauge.mdx b/apps/docs/content/docs/integrations/gauge.mdx new file mode 100644 index 00000000000..76d4da40f3b --- /dev/null +++ b/apps/docs/content/docs/integrations/gauge.mdx @@ -0,0 +1,826 @@ +--- +title: Gauge +description: Track AI visibility and manage Gauge prompts +integration: + type: "gauge" + color: "#E8FF3F" +--- + +import { ReferenceDetails } from "@/components/ui/reference-details" + + +{/* MANUAL-CONTENT-START:intro */} +[Gauge](https://withgauge.com) measures how brands appear in AI answers and which sources those answers cite. + +With the Gauge block, you can: + +- **Manage tracked questions**: Create topics, import prompts with a dry-run preview, update tracking types, and organize prompts with tags. +- **Investigate AI answers**: Read collected answers, brand mentions, citations, and search queries. +- **Report on visibility**: Discover available metrics and run reports across answers, citations, search queries, and AI traffic. + +Create an organization API key in Gauge under **Settings → Integrations → API Keys**, then connect it as a saved Gauge account in Sim. **Read only** supports reads and report queries; **Read & write** also manages topics, prompts, and tags. Reconnect with a replacement key after rotation, expiry, or revocation. Temporary CLI login tokens are not supported. + +Select an organization with Gauge Chat enabled and its category. Resource pickers also have a manual ID option in advanced mode. Answer pickers show the API's default recent window; use an answer ID directly for older answers. Follow `nextCursor` to retrieve additional result pages. Answer and citation filters default to the last seven days. + +This integration covers all 27 Gauge Chat API operations. Gauge Agents uses a separate API and is outside this block's scope. See the [Gauge Chat API reference](https://docs.withgauge.com/chat/api-reference/overview) for the current limits and query catalog. +{/* MANUAL-CONTENT-END */} + +## Use the block [#usage-instructions] + +Manage organizations, categories, topics, tracked prompts, and tags; inspect brands, AI answers, citations, and search queries; and run visibility, share-of-voice, and AI-traffic reports with the Gauge Chat API. + +## Actions + +### Gauge Get Current Credential + +Read the identity, permissions, and expiry of the connected Gauge API key. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | json | User | +| ↳ `id` | string | Id | +| ↳ `email` | string | Email | +| ↳ `name` | string | Name | +| `apiKey` | json | Api key | +| ↳ `id` | string | Id | +| ↳ `name` | string | Name | +| ↳ `prefix` | string | Prefix | +| ↳ `organization` | json | Organization | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `name` | string | Name | +| `token` | json | Token | +| ↳ `kind` | string | Kind | +| ↳ `scopes` | array | Scopes | +| ↳ `expiresAt` | string | Expires at | + + + +### Gauge List Organizations + +List organizations and agency clients accessible to the connected API key. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `organizations` | array | Organizations | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `name` | string | Name | +| ↳ `products` | json | Products | +| ↳ `chat` | boolean | Chat | +| ↳ `agents` | boolean | Agents | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Get Organization + +Read an organization and its enabled Gauge products. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `organization` | json | Organization | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `name` | string | Name | +| ↳ `products` | json | Products | +| ↳ `chat` | boolean | Chat | +| ↳ `agents` | boolean | Agents | + + + +### Gauge List Categories + +List the AI-search categories configured for an organization. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `categories` | array | Categories | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `name` | string | Name | +| ↳ `description` | string | Description | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Get Category + +Read the details of an AI-search category. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `category` | json | Category | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `name` | string | Name | +| ↳ `description` | string | Description | + + + +### Gauge List Topics + +List one page of tracked-prompt topics in a category. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `cursor` | string | No | Opaque nextCursor from the previous page | +| `limit` | number | No | Maximum results to return \(1–200; defaults to 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topics` | array | Topics | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `title` | string | Title | +| ↳ `promptCount` | number | Prompt count | +| ↳ `createdAt` | string | Created at | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Create Topic + +Create a topic to organize tracked prompts. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `title` | string | Yes | Topic title \(1–200 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topic` | json | Topic | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `title` | string | Title | +| ↳ `promptCount` | number | Prompt count | +| ↳ `createdAt` | string | Created at | + + + +### Gauge Get Topic + +Read a topic and its tracked-prompt count. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `topicId` | string | Yes | Topic ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topic` | json | Topic | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `title` | string | Title | +| ↳ `promptCount` | number | Prompt count | +| ↳ `createdAt` | string | Created at | + + + +### Gauge Rename Topic + +Change the title of an existing topic. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `topicId` | string | Yes | Topic ID | +| `title` | string | Yes | Topic title \(1–200 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topic` | json | Topic | +| ↳ `id` | string | Id | +| ↳ `slug` | string | Slug | +| ↳ `title` | string | Title | +| ↳ `promptCount` | number | Prompt count | +| ↳ `createdAt` | string | Created at | + + + +### Gauge List Prompts + +List one page of tracked prompts with optional topic, tag, and tracking-type filters. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `topic` | string | No | Filter by a single topic ID | +| `tag` | json | No | JSON array of tag IDs | +| `trackingType` | string | No | Prompt tracking type: visibility or branded_own or all | +| `cursor` | string | No | Opaque nextCursor from the previous page | +| `limit` | number | No | Maximum results to return \(1–200; defaults to 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `prompts` | array | Prompts | +| ↳ `id` | string | Id | +| ↳ `text` | string | Text | +| ↳ `topicId` | string | Topic id | +| ↳ `trackingType` | string | Tracking type | +| ↳ `tagIds` | array | Tag ids | +| ↳ `createdAt` | string | Created at | +| ↳ `firstAnswerDay` | string | First answer day | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Create Prompts + +Add a batch of tracked questions or preview the import without creating prompts. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `dryRun` | boolean | No | Validate the batch and report what would be created without saving prompts | +| `prompts` | json | Yes | JSON array of 1–500 prompts: \[\{"text":"Which tools work best?","topicId":"top_example","trackingType":"visibility","tagIds":\[\]\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Results | +| ↳ `index` | number | Index | +| ↳ `status` | string | Status | +| ↳ `prompt` | json | Prompt | +| ↳ `id` | string | Id | +| ↳ `text` | string | Text | +| ↳ `topicId` | string | Topic id | +| ↳ `trackingType` | string | Tracking type | +| ↳ `tagIds` | array | Tag ids | +| ↳ `createdAt` | string | Created at | +| ↳ `firstAnswerDay` | string | First answer day | +| `dryRun` | boolean | Dry run | +| `remaining` | number | Remaining | + + + +### Gauge Bulk Update Prompts + +Change the topic or tracking type of up to 500 tracked prompts. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `topicId` | string | No | New topic ID; omitted values leave the topic unchanged | +| `trackingType` | string | No | Prompt tracking type: visibility or branded_own | +| `ids` | json | Yes | JSON array of 1–500 prompt IDs to update | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updated` | array | Updated | +| `notFound` | array | Not found | + + + +### Gauge Add Tags To Prompts + +Apply a set of tags to the selected tracked prompts. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `promptIds` | json | Yes | JSON array of 1–500 prompt IDs | +| `tagIds` | json | Yes | JSON array of 1–50 tag IDs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `promptIds` | array | Prompt ids | + + + +### Gauge Remove Tags From Prompts + +Remove a set of tags from the selected tracked prompts. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `promptIds` | json | Yes | JSON array of 1–500 prompt IDs | +| `tagIds` | json | Yes | JSON array of 1–50 tag IDs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `promptIds` | array | Prompt ids | + + + +### Gauge Get Prompt + +Read a tracked question and its topic, tags, and sampling information. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `promptId` | string | Yes | Prompt ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `prompt` | json | Prompt | +| ↳ `id` | string | Id | +| ↳ `text` | string | Text | +| ↳ `topicId` | string | Topic id | +| ↳ `trackingType` | string | Tracking type | +| ↳ `tagIds` | array | Tag ids | +| ↳ `createdAt` | string | Created at | +| ↳ `firstAnswerDay` | string | First answer day | + + + +### Gauge Update Prompt + +Move a prompt to another topic or change its tracking type. Prompt text is immutable: create a new prompt instead. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `promptId` | string | Yes | Prompt ID | +| `topicId` | string | No | New topic ID; omitted values leave the topic unchanged | +| `trackingType` | string | No | Prompt tracking type: visibility or branded_own | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `prompt` | json | Prompt | +| ↳ `id` | string | Id | +| ↳ `text` | string | Text | +| ↳ `topicId` | string | Topic id | +| ↳ `trackingType` | string | Tracking type | +| ↳ `tagIds` | array | Tag ids | +| ↳ `createdAt` | string | Created at | +| ↳ `firstAnswerDay` | string | First answer day | + + + +### Gauge List Tags + +List the tags available to organize prompts in an organization. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | array | Tags | +| ↳ `id` | string | Id | +| ↳ `name` | string | Name | +| ↳ `color` | string | Color | +| ↳ `promptCount` | number | Prompt count | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Create Tag + +Create an organization-wide prompt tag. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `name` | string | Yes | Tag name \(1–120 characters\) | +| `color` | string | No | Tag color string \(up to 20 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | json | Tag | +| ↳ `id` | string | Id | +| ↳ `name` | string | Name | +| ↳ `color` | string | Color | +| ↳ `promptCount` | number | Prompt count | + + + +### Gauge Get Tag + +Read a prompt tag and its usage count. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `tagId` | string | Yes | Tag ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | json | Tag | +| ↳ `id` | string | Id | +| ↳ `name` | string | Name | +| ↳ `color` | string | Color | +| ↳ `promptCount` | number | Prompt count | + + + +### Gauge Update Tag + +Change the name or color of a prompt tag. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `tagId` | string | Yes | Tag ID | +| `name` | string | No | Tag name \(1–120 characters\) | +| `color` | string | No | Tag color string \(up to 20 characters\); omit to keep unchanged or use "" to clear | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | json | Tag | +| ↳ `id` | string | Id | +| ↳ `name` | string | Name | +| ↳ `color` | string | Color | +| ↳ `promptCount` | number | Prompt count | + + + +### Gauge Delete Tag + +Delete a prompt tag and remove its associations with prompts. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `tagId` | string | Yes | Tag ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tagId` | string | ID of the deleted tag | +| `deleted` | boolean | Whether the tag was deleted | + + + +### Gauge List Brands + +Your owned brands and the category's tracked competitors. Together these are the share-of-voice denominator. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `brands` | array | Brands | +| ↳ `id` | string | Id | +| ↳ `name` | string | Name | +| ↳ `slug` | string | Slug | +| ↳ `website` | string | Website | +| ↳ `role` | string | Role | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Get Query Metadata + +Discover available report datasets, metrics, dimensions, filters, and values. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `dataset` | string | No | Report dataset: answers, citations, searchQueries, or aiTraffic | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `catalog` | json | Dynamic dataset, metric, dimension, and filter catalog returned by Gauge | + + + +### Gauge Run Report Query + +Run a report on AI answers, citations, search queries, or AI bot traffic using metrics from the query catalog. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `dataset` | string | Yes | Report dataset: answers, citations, searchQueries, or aiTraffic | +| `dimensions` | json | No | JSON array of up to 3 dimension keys from Get Query Metadata | +| `metrics` | json | Yes | JSON array of 1–4 metric keys from Get Query Metadata | +| `filters` | json | No | JSON object of category, brand, model, topic, tag, tagMode, prompt, trackingType, domain, and page filters | +| `where` | json | No | JSON array of up to 10 predicates: \{"dimension":"domain","op":"contains","value":"example.com"\} | +| `dateRange` | json | No | JSON object with since \(YYYY-MM-DD or 30d\), until \(YYYY-MM-DD\), or lastDays \(1–400\) | +| `granularity` | string | No | Report time bucket: hour, day, week, or month \(defaults to day\) | +| `orderBy` | json | No | JSON array of up to 4 sort rules: \{"key":"metric_name","dir":"desc"\} | +| `limit` | number | No | Maximum report rows \(1–1000; defaults to 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `columns` | array | Columns | +| ↳ `key` | string | Key | +| ↳ `kind` | string | Kind | +| ↳ `type` | string | Type | +| ↳ `unit` | string | Unit | +| `rows` | array | Rows | +| `labels` | json | Labels | +| `dateRange` | json | Date range | +| ↳ `since` | string | Since | +| ↳ `until` | string | Until | +| `truncated` | boolean | Truncated | +| `cached` | boolean | Cached | +| `freshness` | json | Freshness | +| ↳ `watermark` | string | Watermark | +| ↳ `pending` | number | Pending | +| ↳ `available` | boolean | Available | + + + +### Gauge List Answers + +List one page of collected AI answers using date, model, prompt, and brand filters. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `since` | string | No | Start date in YYYY-MM-DD format; defaults to 7 days ago | +| `until` | string | No | End date in YYYY-MM-DD format; defaults to today | +| `model` | json | No | JSON array of model IDs | +| `prompt` | json | No | JSON array of prompt IDs | +| `topic` | json | No | JSON array of topic IDs | +| `tag` | json | No | JSON array of tag IDs | +| `trackingType` | string | No | Prompt tracking type: visibility or branded_own or all | +| `cursor` | string | No | Opaque nextCursor from the previous page | +| `limit` | number | No | Maximum results to return \(1–100; defaults to 50\) | +| `brand` | json | No | JSON array of brand IDs that an answer must mention | +| `brandAbsent` | json | No | JSON array of brand IDs that an answer must not mention | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `answers` | array | Answers | +| ↳ `id` | string | Id | +| ↳ `promptId` | string | Prompt id | +| ↳ `topicId` | string | Topic id | +| ↳ `model` | string | Model | +| ↳ `day` | string | Day | +| ↳ `createdAt` | string | Created at | +| ↳ `text` | string | Text | +| ↳ `brandIds` | array | Brand ids | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +### Gauge Get Answer + +Read an AI answer with its prompt, brand mentions, source citations, and searches. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `answerId` | string | Yes | Answer ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `answer` | json | Answer | +| ↳ `id` | string | Id | +| ↳ `promptId` | string | Prompt id | +| ↳ `promptText` | string | Prompt text | +| ↳ `topicId` | string | Topic id | +| ↳ `model` | string | Model | +| ↳ `day` | string | Day | +| ↳ `createdAt` | string | Created at | +| ↳ `text` | string | Text | +| ↳ `mentions` | array | Mentions | +| ↳ `brandId` | string | Brand id | +| ↳ `name` | string | Name | +| ↳ `mentionTypes` | array | Mention types | +| ↳ `count` | number | Count | +| ↳ `citations` | array | Citations | +| ↳ `pageId` | string | Page id | +| ↳ `url` | string | Url | +| ↳ `title` | string | Title | +| ↳ `domain` | string | Domain | +| ↳ `searchQueries` | array | Search queries | + + + +### Gauge List Citations + +List one page of sources cited by AI answers in a category. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `org` | string | Yes | Organization slug or UUID | +| `category` | string | Yes | Category slug or cat_ ID | +| `since` | string | No | Start date in YYYY-MM-DD format; defaults to 7 days ago | +| `until` | string | No | End date in YYYY-MM-DD format; defaults to today | +| `model` | json | No | JSON array of model IDs | +| `prompt` | json | No | JSON array of prompt IDs | +| `topic` | json | No | JSON array of topic IDs | +| `tag` | json | No | JSON array of tag IDs | +| `trackingType` | string | No | Prompt tracking type: visibility or branded_own or all | +| `cursor` | string | No | Opaque nextCursor from the previous page | +| `limit` | number | No | Maximum results to return \(1–200; defaults to 50\) | +| `domain` | json | No | JSON array of cited domains | +| `page` | json | No | JSON array of cited page IDs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `citations` | array | Citations | +| ↳ `answerId` | string | Answer id | +| ↳ `pageId` | string | Page id | +| ↳ `promptId` | string | Prompt id | +| ↳ `model` | string | Model | +| ↳ `day` | string | Day | +| ↳ `domain` | string | Domain | +| ↳ `url` | string | Url | +| `nextCursor` | string | Opaque cursor for the next page; null when complete | + + + +{/* MANUAL-CONTENT-START:notes */} +## Report requirements + +Answer lists support up to 100 results per page; other paginated lists support up to 200. + +Read the query catalog before building reports. Visibility and share-of-voice metrics require a brand filter or brand grouping. Dataset filters and supported time granularities vary. +{/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/integrations/gitlab.mdx b/apps/docs/content/docs/integrations/gitlab.mdx index c65f0df9892..7f88acc2e1b 100644 --- a/apps/docs/content/docs/integrations/gitlab.mdx +++ b/apps/docs/content/docs/integrations/gitlab.mdx @@ -2127,6 +2127,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when a comment is added on a commit, merge request, or issue + + #### Configuration | Parameter | Type | Required | Description | @@ -2157,13 +2159,14 @@ Trigger workflow when a comment is added on a commit, merge request, or issue | ↳ `action` | string | Action \(create, update\) | | ↳ `url` | string | Comment URL | - ---- + ### GitLab Event Trigger workflow from any GitLab webhook event + + #### Configuration | Parameter | Type | Required | Description | @@ -2186,8 +2189,7 @@ Trigger workflow from any GitLab webhook event | ↳ `path_with_namespace` | string | Full path \(namespace/project\) | | `object_attributes` | json | Event-specific attributes \(varies by object_kind\) | - ---- + ### GitLab Issue @@ -2320,6 +2322,8 @@ Trigger workflow when a pipeline status changes in GitLab Trigger workflow when commits are pushed to a GitLab project + + #### Configuration | Parameter | Type | Required | Description | @@ -2350,3 +2354,5 @@ Trigger workflow when commits are pushed to a GitLab project | ↳ `path_with_namespace` | string | Full path \(namespace/project\) | | `commits` | json | Array of commit objects included in this push | + + diff --git a/apps/docs/content/docs/integrations/gmail.mdx b/apps/docs/content/docs/integrations/gmail.mdx index 9457e9ca524..f5928706260 100644 --- a/apps/docs/content/docs/integrations/gmail.mdx +++ b/apps/docs/content/docs/integrations/gmail.mdx @@ -396,6 +396,8 @@ These triggers poll for new data on a schedule. Triggers when new emails are received in Gmail (requires Gmail credentials) + + #### Configuration | Parameter | Type | Required | Description | @@ -426,3 +428,5 @@ Triggers when new emails are received in Gmail (requires Gmail credentials) | ↳ `attachments` | file[] | Array of email attachments as files \(if includeAttachments is enabled\) | | `timestamp` | string | Event timestamp | + + diff --git a/apps/docs/content/docs/integrations/google_ads.mdx b/apps/docs/content/docs/integrations/google_ads.mdx index 47454b2d3a3..284899ef02c 100644 --- a/apps/docs/content/docs/integrations/google_ads.mdx +++ b/apps/docs/content/docs/integrations/google_ads.mdx @@ -45,6 +45,8 @@ Connect to Google Ads to list accessible accounts, list campaigns, view ad group List Google Ads accounts directly accessible by the authenticated user; query customer_client for manager subaccounts + + #### Input | Parameter | Type | Required | Description | @@ -57,10 +59,14 @@ List Google Ads accounts directly accessible by the authenticated user; query cu | `customerIds` | array | Customer IDs directly accessible by the user | | `totalCount` | number | Total number of accessible customer accounts | + + ### Google Ads Search (GAQL) Run a custom Google Ads Query Language (GAQL) query + + #### Input | Parameter | Type | Required | Description | @@ -78,10 +84,14 @@ Run a custom Google Ads Query Language (GAQL) query | `totalResultsCount` | number | Total matching rows ignoring LIMIT, when returned by Google | | `nextPageToken` | string | Token for the next page; keep the same query and customer | + + ### List Google Ads Campaigns List campaigns in a Google Ads account with optional status filtering + + #### Input | Parameter | Type | Required | Description | @@ -107,10 +117,14 @@ List campaigns in a Google Ads account with optional status filtering | ↳ `budgetAmountMicros` | string | Daily budget in micros \(divide by 1,000,000 for currency value\) | | `totalCount` | number | Number of campaigns in this page | + + ### Google Ads Campaign Performance Get performance metrics for Google Ads campaigns over a date range + + #### Input | Parameter | Type | Required | Description | @@ -140,10 +154,14 @@ Get performance metrics for Google Ads campaigns over a date range | ↳ `date` | string | Date for this row \(YYYY-MM-DD\) | | `totalCount` | number | Number of rows in this page | + + ### List Google Ads Ad Groups List ad groups in a Google Ads campaign + + #### Input | Parameter | Type | Required | Description | @@ -169,6 +187,8 @@ List ad groups in a Google Ads campaign | ↳ `campaignName` | string | Parent campaign name | | `totalCount` | number | Number of ad groups in this page | + + ### Google Ads Ad Performance Get performance metrics for individual ads over a date range diff --git a/apps/docs/content/docs/integrations/google_appsheet.mdx b/apps/docs/content/docs/integrations/google_appsheet.mdx index 125e3c18815..261cd4b77ce 100644 --- a/apps/docs/content/docs/integrations/google_appsheet.mdx +++ b/apps/docs/content/docs/integrations/google_appsheet.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Google AppSheet](https://about.appsheet.com/) to find, add, edit, and delete table rows. Use Selector expressions to filter results, and identify existing rows by their key column. @@ -23,7 +25,6 @@ Google AppSheet authenticates with a static Application Access Key rather than O The [AppSheet API requires an Enterprise plan](https://support.google.com/appsheet/answer/10105769?hl=en). An organization policy can prevent app creators from enabling external API access. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key. @@ -34,6 +35,8 @@ Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Re Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results. + + #### Input | Parameter | Type | Required | Description | @@ -52,10 +55,14 @@ Read rows from an AppSheet table. Omit the selector to return every row, or prov | `metadata` | json | Operation metadata | | ↳ `rowCount` | number | Number of rows returned | + + ### AppSheet Add Rows Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()). + + #### Input | Parameter | Type | Required | Description | @@ -74,10 +81,14 @@ Add new rows to an AppSheet table. The key column value must be provided explici | `metadata` | json | Operation metadata | | ↳ `rowCount` | number | Number of rows added | + + ### AppSheet Edit Rows Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change. + + #### Input | Parameter | Type | Required | Description | @@ -96,10 +107,14 @@ Update existing rows in an AppSheet table. Each row must explicitly include the | `metadata` | json | Operation metadata | | ↳ `rowCount` | number | Number of rows updated | + + ### AppSheet Delete Rows Delete rows from an AppSheet table. Each row only needs to include the key column name and value. + + #### Input | Parameter | Type | Required | Description | @@ -118,4 +133,6 @@ Delete rows from an AppSheet table. Each row only needs to include the key colum | `metadata` | json | Operation metadata | | ↳ `rowCount` | number | Number of rows deleted | + + diff --git a/apps/docs/content/docs/integrations/google_books.mdx b/apps/docs/content/docs/integrations/google_books.mdx index 103eed9dc49..cc9b73b7d5b 100644 --- a/apps/docs/content/docs/integrations/google_books.mdx +++ b/apps/docs/content/docs/integrations/google_books.mdx @@ -68,6 +68,8 @@ Search for books using the Google Books API Get detailed information about a specific book volume + + #### Input | Parameter | Type | Required | Description | @@ -98,4 +100,6 @@ Get detailed information about a specific book volume | `isbn10` | string | ISBN-10 identifier | | `isbn13` | string | ISBN-13 identifier | + + diff --git a/apps/docs/content/docs/integrations/google_contacts.mdx b/apps/docs/content/docs/integrations/google_contacts.mdx index 8d536b29945..90220a0443a 100644 --- a/apps/docs/content/docs/integrations/google_contacts.mdx +++ b/apps/docs/content/docs/integrations/google_contacts.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Google Contacts](https://contacts.google.com/) stores contact records for a Google account. Search or list contacts to obtain their resource names, then use those names to read, update, or delete records. Create contacts with the name, email, phone, and other fields needed by your workflow. {/* MANUAL-CONTENT-END */} - ## OAuth scopes [#oauth-scopes] Sim requests these scopes when someone connects a Google Contacts account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup. @@ -37,6 +38,8 @@ Create, read, update, delete, list, and search contacts. Create a new contact in Google Contacts + + #### Input | Parameter | Type | Required | Description | @@ -58,10 +61,14 @@ Create a new contact in Google Contacts | `content` | string | Contact creation confirmation message | | `metadata` | json | Created contact metadata including resource name and details | + + ### Google Contacts Get Get a specific contact from Google Contacts + + #### Input | Parameter | Type | Required | Description | @@ -75,10 +82,14 @@ Get a specific contact from Google Contacts | `content` | string | Contact retrieval confirmation message | | `metadata` | json | Contact details including name, email, phone, and organization | + + ### Google Contacts List List contacts from Google Contacts + + #### Input | Parameter | Type | Required | Description | @@ -94,10 +105,14 @@ List contacts from Google Contacts | `content` | string | Summary of found contacts count | | `metadata` | json | List of contacts with pagination tokens | + + ### Google Contacts Search Search contacts in Google Contacts by name, email, phone, or organization + + #### Input | Parameter | Type | Required | Description | @@ -112,10 +127,14 @@ Search contacts in Google Contacts by name, email, phone, or organization | `content` | string | Summary of search results count | | `metadata` | json | Search results with matching contacts | + + ### Google Contacts Update Update an existing contact in Google Contacts + + #### Input | Parameter | Type | Required | Description | @@ -139,10 +158,14 @@ Update an existing contact in Google Contacts | `content` | string | Contact update confirmation message | | `metadata` | json | Updated contact metadata | + + ### Google Contacts Delete Delete a contact from Google Contacts + + #### Input | Parameter | Type | Required | Description | @@ -156,4 +179,6 @@ Delete a contact from Google Contacts | `content` | string | Contact deletion confirmation message | | `metadata` | json | Deletion details including resource name | + + diff --git a/apps/docs/content/docs/integrations/google_drive.mdx b/apps/docs/content/docs/integrations/google_drive.mdx index ca172a07a7f..f70b5e1c0c0 100644 --- a/apps/docs/content/docs/integrations/google_drive.mdx +++ b/apps/docs/content/docs/integrations/google_drive.mdx @@ -985,6 +985,8 @@ These triggers poll for new data on a schedule. Triggers when files are created, modified, or deleted in Google Drive + + #### Configuration | Parameter | Type | Required | Description | @@ -1015,3 +1017,5 @@ Triggers when files are created, modified, or deleted in Google Drive | `eventType` | string | Change type: "created", "modified", or "deleted" | | `timestamp` | string | Event timestamp in ISO format | + + diff --git a/apps/docs/content/docs/integrations/google_forms.mdx b/apps/docs/content/docs/integrations/google_forms.mdx index cdb9e199a1e..e72ba1e3319 100644 --- a/apps/docs/content/docs/integrations/google_forms.mdx +++ b/apps/docs/content/docs/integrations/google_forms.mdx @@ -315,6 +315,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow from Google Form submissions (via Apps Script forwarder) + + #### Configuration | Parameter | Type | Required | Description | @@ -336,3 +338,5 @@ Trigger workflow from Google Form submissions (via Apps Script forwarder) | `answers` | object | Normalized map of question -> answer | | `raw` | object | Original payload \(when enabled\) | + + diff --git a/apps/docs/content/docs/integrations/google_meet.mdx b/apps/docs/content/docs/integrations/google_meet.mdx index f20cb882302..87a35b8f48d 100644 --- a/apps/docs/content/docs/integrations/google_meet.mdx +++ b/apps/docs/content/docs/integrations/google_meet.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Google Meet](https://meet.google.com/) provides meeting spaces and conference records. Create or configure a meeting space, inspect participants and past conferences, or end an active conference. Select the space or conference identifier required by the operation. {/* MANUAL-CONTENT-END */} - ## OAuth scopes [#oauth-scopes] Sim requests these scopes when someone connects a Google Meet account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup. @@ -38,6 +39,8 @@ Create meeting spaces, get space details, end conferences, list conference recor Create a new Google Meet meeting space + + #### Input | Parameter | Type | Required | Description | @@ -55,10 +58,14 @@ Create a new Google Meet meeting space | `accessType` | string | Access type configuration | | `entryPointAccess` | string | Entry point access configuration | + + ### Google Meet Get Space Get details of a Google Meet meeting space by name or meeting code + + #### Input | Parameter | Type | Required | Description | @@ -76,10 +83,14 @@ Get details of a Google Meet meeting space by name or meeting code | `entryPointAccess` | string | Entry point access configuration | | `activeConference` | string | Active conference record name | + + ### Google Meet End Conference End the active conference in a Google Meet space + + #### Input | Parameter | Type | Required | Description | @@ -92,10 +103,14 @@ End the active conference in a Google Meet space | --------- | ---- | ----------- | | `ended` | boolean | Whether the conference was ended successfully | + + ### Google Meet List Conference Records List conference records for meetings you organized + + #### Input | Parameter | Type | Required | Description | @@ -111,10 +126,14 @@ List conference records for meetings you organized | `conferenceRecords` | json | List of conference records with name, start/end times, and space | | `nextPageToken` | string | Token for next page of results | + + ### Google Meet Get Conference Record Get details of a specific conference record + + #### Input | Parameter | Type | Required | Description | @@ -131,10 +150,14 @@ Get details of a specific conference record | `expireTime` | string | Conference record expiration time | | `space` | string | Associated space resource name | + + ### Google Meet List Participants List participants of a conference record + + #### Input | Parameter | Type | Required | Description | @@ -152,4 +175,6 @@ List participants of a conference record | `nextPageToken` | string | Token for next page of results | | `totalSize` | number | Total number of participants | + + diff --git a/apps/docs/content/docs/integrations/google_sheets.mdx b/apps/docs/content/docs/integrations/google_sheets.mdx index fd148766dd7..97fa3d0edce 100644 --- a/apps/docs/content/docs/integrations/google_sheets.mdx +++ b/apps/docs/content/docs/integrations/google_sheets.mdx @@ -448,6 +448,8 @@ These triggers poll for new data on a schedule. Triggers when new rows are added to a Google Sheet + + #### Configuration | Parameter | Type | Required | Description | @@ -472,3 +474,5 @@ Triggers when new rows are added to a Google Sheet | `sheetName` | string | The sheet tab name | | `timestamp` | string | Event timestamp in ISO format | + + diff --git a/apps/docs/content/docs/integrations/google_tasks.mdx b/apps/docs/content/docs/integrations/google_tasks.mdx index 3ccfca30a3a..85f987a6465 100644 --- a/apps/docs/content/docs/integrations/google_tasks.mdx +++ b/apps/docs/content/docs/integrations/google_tasks.mdx @@ -38,6 +38,8 @@ Create, read, update, delete, and list tasks and task lists. Create a new task in a Google Tasks list + + #### Input | Parameter | Type | Required | Description | @@ -67,6 +69,8 @@ Create a new task in a Google Tasks list | `completed` | string | Completion date | | `deleted` | boolean | Whether the task is deleted | + + ### Google Tasks List Tasks List all tasks in a Google Tasks list @@ -119,6 +123,8 @@ List all tasks in a Google Tasks list Retrieve a specific task by ID from a Google Tasks list + + #### Input | Parameter | Type | Required | Description | @@ -143,10 +149,14 @@ Retrieve a specific task by ID from a Google Tasks list | `completed` | string | Completion date | | `deleted` | boolean | Whether the task is deleted | + + ### Google Tasks Update Task Update an existing task in a Google Tasks list + + #### Input | Parameter | Type | Required | Description | @@ -175,10 +185,14 @@ Update an existing task in a Google Tasks list | `completed` | string | Completion date | | `deleted` | boolean | Whether the task is deleted | + + ### Google Tasks Delete Task Delete a task from a Google Tasks list + + #### Input | Parameter | Type | Required | Description | @@ -193,10 +207,14 @@ Delete a task from a Google Tasks list | `taskId` | string | Deleted task ID | | `deleted` | boolean | Whether deletion was successful | + + ### Google Tasks List Task Lists Retrieve all task lists for the authenticated user + + #### Input | Parameter | Type | Required | Description | @@ -215,4 +233,6 @@ Retrieve all task lists for the authenticated user | ↳ `selfLink` | string | URL pointing to this task list | | `nextPageToken` | string | Token for retrieving the next page of results | + + diff --git a/apps/docs/content/docs/integrations/google_translate.mdx b/apps/docs/content/docs/integrations/google_translate.mdx index 82e419d6b0e..4c0ae99efc2 100644 --- a/apps/docs/content/docs/integrations/google_translate.mdx +++ b/apps/docs/content/docs/integrations/google_translate.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Google Cloud Translation](https://cloud.google.com/translate) translates text and detects its source language. Provide text and a target language for translation, or use language detection when the source language is unknown. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language. @@ -22,6 +23,8 @@ Translate and detect languages using the Google Cloud Translation API. Supports Translate text between languages using the Google Cloud Translation API. Supports auto-detection of the source language. + + #### Input | Parameter | Type | Required | Description | @@ -39,10 +42,14 @@ Translate text between languages using the Google Cloud Translation API. Support | `translatedText` | string | The translated text | | `detectedSourceLanguage` | string | The detected source language code \(if source was not specified\) | + + ### Google Translate Detect Language Detect the language of text using the Google Cloud Translation API. + + #### Input | Parameter | Type | Required | Description | @@ -57,4 +64,6 @@ Detect the language of text using the Google Cloud Translation API. | `language` | string | The detected language code \(e.g., "en", "es", "fr"\) | | `confidence` | number | Confidence score of the detection | + + diff --git a/apps/docs/content/docs/integrations/granola.mdx b/apps/docs/content/docs/integrations/granola.mdx index bf09496e4aa..483206f7571 100644 --- a/apps/docs/content/docs/integrations/granola.mdx +++ b/apps/docs/content/docs/integrations/granola.mdx @@ -333,6 +333,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow on any Granola note event + + #### Configuration | Parameter | Type | Required | Description | @@ -352,13 +354,14 @@ Trigger workflow on any Granola note event | `changed_fields` | json | Note fields that changed. Present on note.edited events \(currently always \["summary"\]\); null otherwise. | | `payload` | json | Full raw webhook body as delivered by Granola. | - ---- + ### Granola Note Access Granted Trigger workflow when a Granola note is shared with you, directly or via a folder + + #### Configuration | Parameter | Type | Required | Description | @@ -378,13 +381,14 @@ Trigger workflow when a Granola note is shared with you, directly or via a folde | `changed_fields` | json | Note fields that changed. Present on note.edited events \(currently always \["summary"\]\); null otherwise. | | `payload` | json | Full raw webhook body as delivered by Granola. | - ---- + ### Granola Note Edited Trigger workflow when a Granola note summary is edited or regenerated + + #### Configuration | Parameter | Type | Required | Description | @@ -404,13 +408,14 @@ Trigger workflow when a Granola note summary is edited or regenerated | `changed_fields` | json | Note fields that changed. Present on note.edited events \(currently always \["summary"\]\); null otherwise. | | `payload` | json | Full raw webhook body as delivered by Granola. | - ---- + ### Granola Note Generated Trigger workflow when the first AI summary for a Granola note is generated + + #### Configuration | Parameter | Type | Required | Description | @@ -430,3 +435,5 @@ Trigger workflow when the first AI summary for a Granola note is generated | `changed_fields` | json | Note fields that changed. Present on note.edited events \(currently always \["summary"\]\); null otherwise. | | `payload` | json | Full raw webhook body as delivered by Granola. | + + diff --git a/apps/docs/content/docs/integrations/greptile.mdx b/apps/docs/content/docs/integrations/greptile.mdx index 78cbba68830..35a2ecf8f76 100644 --- a/apps/docs/content/docs/integrations/greptile.mdx +++ b/apps/docs/content/docs/integrations/greptile.mdx @@ -6,12 +6,13 @@ integration: color: "#181C1E" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Greptile](https://www.greptile.com/) searches and answers questions about code repositories. Index a repository and check its status before querying it, then use natural-language questions or code search to retrieve answers and relevant files. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Query and search codebases using natural language with Greptile. Get AI-generated answers about your code, find relevant files, and understand complex codebases. @@ -22,6 +23,8 @@ Query and search codebases using natural language with Greptile. Get AI-generate Query repositories in natural language and get answers with relevant code references. Greptile uses AI to understand your codebase and answer questions. + + #### Input | Parameter | Type | Required | Description | @@ -48,10 +51,14 @@ Query repositories in natural language and get answers with relevant code refere | ↳ `summary` | string | Summary of the code section | | ↳ `distance` | number | Similarity score \(lower = more relevant\) | + + ### Greptile Index Repository Submit a repository to be indexed by Greptile. Indexing must complete before the repository can be queried. Small repos take 3-5 minutes, larger ones can take over an hour. + + #### Input | Parameter | Type | Required | Description | @@ -72,10 +79,14 @@ Submit a repository to be indexed by Greptile. Indexing must complete before the | `statusEndpoint` | string | URL endpoint to check indexing status | | `message` | string | Status message about the indexing operation | + + ### Greptile Repository Status Check the indexing status of a repository. Use this to verify if a repository is ready to be queried or to monitor indexing progress. + + #### Input | Parameter | Type | Required | Description | @@ -100,4 +111,6 @@ Check the indexing status of a repository. Use this to verify if a repository is | `sampleQuestions` | array | Sample questions for the indexed repository | | `sha` | string | Git commit SHA of the indexed version | + + diff --git a/apps/docs/content/docs/integrations/huggingface.mdx b/apps/docs/content/docs/integrations/huggingface.mdx index 9b1695572da..545d678fffa 100644 --- a/apps/docs/content/docs/integrations/huggingface.mdx +++ b/apps/docs/content/docs/integrations/huggingface.mdx @@ -6,12 +6,13 @@ integration: color: "#0B0F19" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Hugging Face](https://huggingface.co/) in Sim to generate text completions with the Hugging Face Inference API. Choose a model and supply the prompt for the workflow step. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Generate completions with the Hugging Face Inference API. @@ -22,6 +23,8 @@ Generate completions with the Hugging Face Inference API. Generate completions using Hugging Face Inference API + + #### Input | Parameter | Type | Required | Description | @@ -47,4 +50,6 @@ Generate completions using Hugging Face Inference API | ↳ `completion_tokens` | number | Number of tokens in the completion | | ↳ `total_tokens` | number | Total number of tokens used | + + diff --git a/apps/docs/content/docs/integrations/hunter.mdx b/apps/docs/content/docs/integrations/hunter.mdx index 31e98a173a8..8def1a85920 100644 --- a/apps/docs/content/docs/integrations/hunter.mdx +++ b/apps/docs/content/docs/integrations/hunter.mdx @@ -23,6 +23,8 @@ Search domains, find email addresses, verify email addresses, discover companies Returns companies matching a set of criteria using Hunter.io AI-powered search. + + #### Input | Parameter | Type | Required | Description | @@ -45,6 +47,8 @@ Returns companies matching a set of criteria using Hunter.io AI-powered search. | ↳ `generic_emails` | number | Count of generic \(role-based\) emails | | ↳ `total_emails` | number | Total emails found for the company | + + ### Hunter Domain Search Returns all the email addresses found using one given domain name, with sources. @@ -185,6 +189,8 @@ Verifies the deliverability of an email address and provides detailed verificati Enriches company data using domain name. + + #### Input | Parameter | Type | Required | Description | @@ -215,6 +221,8 @@ Enriches company data using domain name. | `phone` | string | Company phone number | | `tech` | array | Technologies used by the company | + + ### Hunter Email Count Returns the total number of email addresses found for a domain or company. diff --git a/apps/docs/content/docs/integrations/icypeas.mdx b/apps/docs/content/docs/integrations/icypeas.mdx index 9aabd52db86..e578ba66430 100644 --- a/apps/docs/content/docs/integrations/icypeas.mdx +++ b/apps/docs/content/docs/integrations/icypeas.mdx @@ -6,12 +6,13 @@ integration: color: "#d4d4d4" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Icypeas](https://icypeas.com/) to find a professional email address from a person's name and company domain, or verify an existing address. Results are asynchronous: the integration polls for the result before returning it to the workflow. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Integrate Icypeas to find a professional email address from a name and company domain, or verify whether an existing email is valid and deliverable. Results are returned asynchronously via polling. @@ -22,6 +23,8 @@ Integrate Icypeas to find a professional email address from a name and company d Find a professional email address from a first name, last name, and company domain or name. Submits the search and polls until a result is available. Costs 1 credit per found email (https://www.icypeas.com/pricing). + + #### Input | Parameter | Type | Required | Description | @@ -42,10 +45,14 @@ Find a professional email address from a first name, last name, and company doma | `firstname` | string | Found person's first name | | `lastname` | string | Found person's last name | + + ### Icypeas Verify Email Verify whether an email address is valid and deliverable. Submits the verification and polls until a result is available. Costs 0.1 credit per verification (https://www.icypeas.com/pricing). + + #### Input | Parameter | Type | Required | Description | @@ -63,4 +70,6 @@ Verify whether an email address is valid and deliverable. Submits the verificati | `item` | json | Full raw item object returned by the Icypeas results endpoint | | `valid` | boolean | Whether the email is valid/deliverable \(true for FOUND/DEBITED status\) | + + diff --git a/apps/docs/content/docs/integrations/incidentio.mdx b/apps/docs/content/docs/integrations/incidentio.mdx index 43f95eee63e..5e9f8f96c29 100644 --- a/apps/docs/content/docs/integrations/incidentio.mdx +++ b/apps/docs/content/docs/integrations/incidentio.mdx @@ -2393,6 +2393,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when an alert is created in incident.io + + #### Configuration | Parameter | Type | Required | Description | @@ -2417,13 +2419,14 @@ Trigger workflow when an alert is created in incident.io | `updated_at` | string | ISO 8601 timestamp when the alert was last updated. | | `resolved_at` | string | ISO 8601 timestamp when the alert was resolved, when applicable. | - ---- + ### incident.io Incident Created Trigger workflow when an incident is created in incident.io + + #### Configuration | Parameter | Type | Required | Description | @@ -2452,13 +2455,14 @@ Trigger workflow when an incident is created in incident.io | `previous_status` | json | Previous status object \(status-updated events only; null otherwise\). | | `update_message` | string | Update message accompanying a status change \(status-updated events only; null otherwise\). | - ---- + ### incident.io Incident Status Updated Trigger workflow when an incident's status changes in incident.io + + #### Configuration | Parameter | Type | Required | Description | @@ -2487,13 +2491,14 @@ Trigger workflow when an incident's status changes in incident.io | `previous_status` | json | Previous status object \(status-updated events only; null otherwise\). | | `update_message` | string | Update message accompanying a status change \(status-updated events only; null otherwise\). | - ---- + ### incident.io Incident Updated Trigger workflow when an incident is updated in incident.io + + #### Configuration | Parameter | Type | Required | Description | @@ -2522,3 +2527,5 @@ Trigger workflow when an incident is updated in incident.io | `previous_status` | json | Previous status object \(status-updated events only; null otherwise\). | | `update_message` | string | Update message accompanying a status change \(status-updated events only; null otherwise\). | + + diff --git a/apps/docs/content/docs/integrations/intercom.mdx b/apps/docs/content/docs/integrations/intercom.mdx index cd69ffd8ac8..a5aeb158d97 100644 --- a/apps/docs/content/docs/integrations/intercom.mdx +++ b/apps/docs/content/docs/integrations/intercom.mdx @@ -1207,6 +1207,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when a new lead is created in Intercom + + #### Configuration | Parameter | Type | Required | Description | @@ -1225,13 +1227,14 @@ Trigger workflow when a new lead is created in Intercom | `first_sent_at` | number | Unix timestamp of first delivery attempt | | `data` | json | Event data containing the contact object. Access via data.item for contact details including id, role, email, name, phone, external_id, custom_attributes, location, avatar, tags, companies, and timestamps | - ---- + ### Intercom Conversation Closed Trigger workflow when a conversation is closed in Intercom + + #### Configuration | Parameter | Type | Required | Description | @@ -1250,13 +1253,14 @@ Trigger workflow when a conversation is closed in Intercom | `first_sent_at` | number | Unix timestamp of first delivery attempt | | `data` | json | Event data containing the conversation object. Access via data.item for conversation details including id, state, open, assignee, contacts, conversation_parts, tags, and source | - ---- + ### Intercom Conversation Created Trigger workflow when a new conversation is created in Intercom + + #### Configuration | Parameter | Type | Required | Description | @@ -1275,13 +1279,14 @@ Trigger workflow when a new conversation is created in Intercom | `first_sent_at` | number | Unix timestamp of first delivery attempt | | `data` | json | Event data containing the conversation object. Access via data.item for conversation details including id, state, open, assignee, contacts, conversation_parts, tags, and source | - ---- + ### Intercom Conversation Reply Trigger workflow when someone replies to an Intercom conversation + + #### Configuration | Parameter | Type | Required | Description | @@ -1300,13 +1305,14 @@ Trigger workflow when someone replies to an Intercom conversation | `first_sent_at` | number | Unix timestamp of first delivery attempt | | `data` | json | Event data containing the conversation object. Access via data.item for conversation details including id, state, open, assignee, contacts, conversation_parts, tags, and source | - ---- + ### Intercom User Created Trigger workflow when a new user is created in Intercom + + #### Configuration | Parameter | Type | Required | Description | @@ -1325,13 +1331,14 @@ Trigger workflow when a new user is created in Intercom | `first_sent_at` | number | Unix timestamp of first delivery attempt | | `data` | json | Event data containing the contact object. Access via data.item for contact details including id, role, email, name, phone, external_id, custom_attributes, location, avatar, tags, companies, and timestamps | - ---- + ### Intercom Webhook (All Events) Trigger workflow on any Intercom webhook event + + #### Configuration | Parameter | Type | Required | Description | @@ -1350,3 +1357,5 @@ Trigger workflow on any Intercom webhook event | `first_sent_at` | number | Unix timestamp of first delivery attempt | | `data` | json | Event data containing the affected object. Access via data.item for the resource \(conversation, contact, company, ticket, etc.\) | + + diff --git a/apps/docs/content/docs/integrations/jama.mdx b/apps/docs/content/docs/integrations/jama.mdx new file mode 100644 index 00000000000..21cc6f8d86b --- /dev/null +++ b/apps/docs/content/docs/integrations/jama.mdx @@ -0,0 +1,901 @@ +--- +title: Jama Connect +description: Manage requirements, traceability, and verification in Jama Connect +integration: + type: "jama" + color: "#E25100" +--- + +import { ReferenceDetails } from "@/components/ui/reference-details" + + +{/* MANUAL-CONTENT-START:intro */} +[Jama Connect](https://www.jamasoftware.com/) manages requirements, their relationships, and verification records. Use the Jama Connect block to search and edit items, inspect relationships and comments, compare versions and baselines, and work with test execution results. + +## Connect your account + +In Jama Connect, open your profile and select **Set API credentials**. Create a client ID and secret, then save them with your Jama instance URL in the block's **Jama Account** field. Use the instance's base URL, such as `https://example.jamacloud.com`, including a context path if your self-hosted instance uses one. + +Jama uses the OAuth client credentials grant for these personal API credentials. Sim obtains short-lived access tokens when needed. The credentials retain their owner's Jama permissions; they do not grant administrator access. Jama requires a **Named Creator** license and REST API access. This authentication method supports Jama Cloud and self-hosted Jama Connect 8.62 or later, including instances that use SSO. See [Jama's API authentication guide](https://dev.jamasoftware.com/api/#advanced-authentication). + +## Work with requirements and tests + +- **Custom fields:** Use **Get Item Type** to read the internal field names, types, and required fields before creating or updating an item. Use **List Pick List Options** for valid option IDs. Field names and status values vary by instance. +- **Create items:** Select a project and item type, then supply **Fields** as a JSON object. **Parent Item ID** places the new item inside an existing container. Sets and folders also require **Child Item Type ID**. +- **Update items:** Supply a JSON Patch array, for example `[{"op":"replace","path":"/fields/name","value":"Updated requirement"}]`. Only the listed paths change. The supported operations are `add`, `remove`, and `replace`. +- **Search:** **Search Items** uses Jama's abstract item search, which can return requirements, test plans, test cycles, test runs, and attachments. Results include their item type and current fields. File downloads are not included. +- **Filters:** For a saved filter with `projectScope` set to `CURRENT`, provide **Project** when listing its results. +- **Test execution:** Read the test run and its item type before updating execution fields. Use Jama's configured field names and status values, and supply ISO 8601 timestamps for execution dates. +- **Pagination:** List and search operations return one page, with a default of 20 and a maximum of 50 results. Set the next **Start Offset** to `pageInfo.startIndex + pageInfo.resultCount` and stop when it reaches `pageInfo.totalResults`, or when the page is empty. + +Requests use the supported `/rest/v1` API. Read requests and idempotent updates retry bounded transient failures; creates and JSON Patch updates are not automatically replayed. Jama Cloud and self-hosted instances can apply their own API throttling. See [Jama's API cookbook](https://dev.jamasoftware.com/cookbook/). +{/* MANUAL-CONTENT-END */} + +## Use the block [#usage-instructions] + +Search and manage requirements, inspect custom item types and pick lists, trace upstream and downstream relationships, collaborate with comments, compare item versions and baselines, and read test plans, cycles, and runs or update execution results. Connect with saved Jama API client credentials that retain the account owner’s permissions. + +## Actions + +### Jama List Projects + +List Projects in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | array | List Projects result | + + + +### Jama Get Project + +Get Project in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | Get Project result | + + + +### Jama List Item Types + +List Item Types in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | No | Only item types enabled in this project | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `itemTypes` | array | List Item Types result | + + + +### Jama Get Item Type + +Get Item Type in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemTypeId` | string | Yes | itemType ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `itemType` | object | Get Item Type result | + + + +### Jama List Items + +List Items in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | Yes | project ID | +| `rootOnly` | boolean | No | Only root-level items in the project | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | List Items result | + + + +### Jama Search Items + +Search Items in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `query` | string | Yes | Text to find in item names, descriptions, and fields | +| `projectId` | string | No | Restrict search to a project | +| `itemTypeId` | string | No | Restrict search to an item type | +| `documentKey` | string | No | Exact document key | +| `modifiedSince` | string | No | Only items modified since this ISO 8601 date | +| `sortBy` | string | No | Sort field and direction, for example modifiedDate.desc | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Search Items result | + + + +### Jama Get Item + +Get Item in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | object | Get Item result | + + + +### Jama Create Item + +Create Item in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | project ID | +| `itemTypeId` | string | Yes | itemType ID | +| `fields` | json | Yes | Jama field names and values, including name and required custom fields | +| `parentItemId` | string | No | Parent item ID; otherwise create at the project root | +| `childItemTypeId` | string | No | Child item type ID, required for sets and folders | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Created resource ID | +| `location` | string | API URL of the created resource | + + + +### Jama Update Item + +Update Item in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemId` | string | Yes | item ID | +| `patch` | json | Yes | JSON Patch array using add, remove, or replace, for example \[\{"op":"replace","path":"/fields/name","value":"Updated name"\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | Successful HTTP status code | + + + +### Jama Delete Item + +Delete Item in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | Successful HTTP status code | + + + +### Jama List Item Children + +List Item Children in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | List Item Children result | + + + +### Jama List Upstream Items + +List Upstream Items in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | List Upstream Items result | + + + +### Jama List Downstream Items + +List Downstream Items in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | List Downstream Items result | + + + +### Jama List Relationships + +List Relationships in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | Yes | project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `relationships` | array | List Relationships result | + + + +### Jama Get Relationship + +Get Relationship in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `relationshipId` | string | Yes | relationship ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `relationship` | object | Get Relationship result | + + + +### Jama Create Relationship + +Create Relationship in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `fromItemId` | string | Yes | fromItem ID | +| `toItemId` | string | Yes | toItem ID | +| `relationshipTypeId` | string | No | Relationship type ID; defaults to the instance default | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Created resource ID | +| `location` | string | API URL of the created resource | + + + +### Jama Delete Relationship + +Delete Relationship in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `relationshipId` | string | Yes | relationship ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | Successful HTTP status code | + + + +### Jama List Relationship Types + +List Relationship Types in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `relationshipTypes` | array | List Relationship Types result | + + + +### Jama List Item Comments + +List Item Comments in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comments` | array | List Item Comments result | + + + +### Jama Create Comment + +Create Comment in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemId` | string | Yes | item ID | +| `text` | string | Yes | Comment text | +| `inReplyTo` | string | No | Parent comment ID when replying | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Created resource ID | +| `location` | string | API URL of the created resource | + + + +### Jama List Item Versions + +List Item Versions in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `itemId` | string | Yes | item ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `versions` | array | List Item Versions result | + + + +### Jama Get Item Version + +Get Item Version in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemId` | string | Yes | item ID | +| `versionNumber` | string | Yes | Version number returned by List Item Versions | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | object | Get Item Version result | + + + +### Jama List Filters + +List Filters in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | No | Only filters for this project | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filters` | array | List Filters result | + + + +### Jama Get Filter + +Get Filter in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filterId` | string | Yes | filter ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filter` | object | Get Filter result | + + + +### Jama List Filter Results + +List Filter Results in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `filterId` | string | Yes | filter ID | +| `projectId` | string | No | Project ID required for a filter whose projectScope is CURRENT | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | List Filter Results result | + + + +### Jama List Baselines + +List Baselines in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | Yes | project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `baselines` | array | List Baselines result | + + + +### Jama Get Baseline + +Get Baseline in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `baselineId` | string | Yes | baseline ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `baseline` | object | Get Baseline result | + + + +### Jama List Baseline Items + +List Baseline Items in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `baselineId` | string | Yes | baseline ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | List Baseline Items result | + + + +### Jama List Test Plans + +List Test Plans in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | Yes | project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `testPlans` | array | List Test Plans result | + + + +### Jama Get Test Plan + +Get Test Plan in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `testPlanId` | string | Yes | testPlan ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `testPlan` | object | Get Test Plan result | + + + +### Jama List Test Cycles + +List Test Cycles in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `testPlanId` | string | Yes | testPlan ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `testCycles` | array | List Test Cycles result | + + + +### Jama Get Test Cycle + +Get Test Cycle in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `testCycleId` | string | Yes | testCycle ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `testCycle` | object | Get Test Cycle result | + + + +### Jama List Test Runs + +List Test Runs in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `testCycleId` | string | Yes | testCycle ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `testRuns` | array | List Test Runs result | + + + +### Jama Get Test Run + +Get Test Run in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `testRunId` | string | Yes | testRun ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `testRun` | object | Get Test Run result | + + + +### Jama Update Test Run + +Update Test Run in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `testRunId` | string | Yes | testRun ID | +| `fields` | json | Yes | Complete execution fields to update, including testRunStatus, testRunSteps, or executionDate as supported by the item type | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | Successful HTTP status code | + + + +### Jama List Releases + +List Releases in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | Yes | project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `releases` | array | List Releases result | + + + +### Jama List Tags + +List Tags in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `projectId` | string | Yes | project ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | array | List Tags result | + + + +### Jama List Pick Lists + +List Pick Lists in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pickLists` | array | List Pick Lists result | + + + +### Jama List Pick List Options + +List Pick List Options in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startAt` | number | No | Zero-based result offset \(default 0\) | +| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) | +| `pickListId` | string | Yes | pickList ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `options` | array | List Pick List Options result | + + + +### Jama Get Current User + +Get Current User in Jama Connect using the connected user's access permissions. + + + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | Get Current User result | + + + + diff --git a/apps/docs/content/docs/integrations/jina.mdx b/apps/docs/content/docs/integrations/jina.mdx index 31cb43eba91..9e2f15758fe 100644 --- a/apps/docs/content/docs/integrations/jina.mdx +++ b/apps/docs/content/docs/integrations/jina.mdx @@ -6,12 +6,13 @@ integration: color: "#333333" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Jina AI](https://jina.ai/) Reader to extract content from a URL, or Search to retrieve web results. Configure the parsing and output-format options for the content your downstream blocks need. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Search the web and get LLM-friendly results, or extract clean content from specific URLs with advanced parsing options. @@ -22,6 +23,8 @@ Search the web and get LLM-friendly results, or extract clean content from speci Extract and process web content into clean, LLM-friendly text using Jina AI Reader. Supports advanced content parsing, link gathering, and multiple output formats with configurable processing options. + + #### Input | Parameter | Type | Required | Description | @@ -49,10 +52,14 @@ Extract and process web content into clean, LLM-friendly text using Jina AI Read | `content` | string | The extracted content from the URL, processed into clean, LLM-friendly text | | `tokensUsed` | number | Number of Jina tokens consumed by this request | + + ### Jina Search Search the web and return top 5 results with LLM-friendly content. Each result is automatically processed through Jina Reader API. Supports geographic filtering, site restrictions, and pagination. + + #### Input | Parameter | Type | Required | Description | @@ -83,4 +90,6 @@ Search the web and return top 5 results with LLM-friendly content. Each result i | ↳ `tokens` | number | Number of tokens consumed by this request | | `tokensUsed` | number | Number of Jina tokens consumed by this request | + + diff --git a/apps/docs/content/docs/integrations/jotform.mdx b/apps/docs/content/docs/integrations/jotform.mdx index 5079f3697cb..c8635101e58 100644 --- a/apps/docs/content/docs/integrations/jotform.mdx +++ b/apps/docs/content/docs/integrations/jotform.mdx @@ -1336,6 +1336,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when a Jotform form receives a new submission + + #### Configuration | Parameter | Type | Required | Description | @@ -1358,3 +1360,5 @@ Trigger workflow when a Jotform form receives a new submission | `rawRequest` | json | The submitted form body. Answers are keyed q\{questionId\}_\{slugifiedLabel\} and hold a string, or an object for a multi-part question such as name or address. A file answer instead appears under the plain slugified label as an array of upload URLs, with the chosen filenames under temp_upload. The body also carries form-internal fields such as slug, buildDate, submitSource, and jsExecutionTracker. | | `raw` | json | Complete original webhook payload from Jotform | + + diff --git a/apps/docs/content/docs/integrations/ketch.mdx b/apps/docs/content/docs/integrations/ketch.mdx index 14ca8435e50..2c1262df10a 100644 --- a/apps/docs/content/docs/integrations/ketch.mdx +++ b/apps/docs/content/docs/integrations/ketch.mdx @@ -6,6 +6,8 @@ integration: color: "#9B5CFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Ketch](https://www.ketch.com/) to read or update consent and subscription preferences and submit data-subject rights requests. @@ -13,7 +15,6 @@ Use [Ketch](https://www.ketch.com/) to read or update consent and subscription p Add a Ketch block and provide your organization code, property code, and environment code. The Ketch Web API is a public API — no API key or OAuth credentials are required. Identity is determined by the organization and property codes along with the data subject's identity (e.g., email address). {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Retrieve and update consent preferences, manage subscription topics and controls, and submit data subject rights requests for access, deletion, correction, or processing restriction. @@ -24,6 +25,8 @@ Retrieve and update consent preferences, manage subscription topics and controls Retrieve consent status for a data subject. Returns the current consent preferences for each configured purpose. + + #### Input | Parameter | Type | Required | Description | @@ -44,10 +47,14 @@ Retrieve consent status for a data subject. Returns the current consent preferen | ↳ `legalBasisCode` | string | Legal basis code \(e.g., "consent_optin", "consent_optout", "disclosure", "other"\) | | `vendors` | object | Map of vendor consent statuses | + + ### Ketch Set Consent Update consent preferences for a data subject. Sets the consent status for specified purposes with the appropriate legal basis. + + #### Input | Parameter | Type | Required | Description | @@ -68,10 +75,14 @@ Update consent preferences for a data subject. Sets the consent status for speci | ↳ `allowed` | string | Consent status for the purpose: "granted" or "denied" | | ↳ `legalBasisCode` | string | Legal basis code \(e.g., "consent_optin", "consent_optout", "disclosure", "other"\) | + + ### Ketch Get Subscriptions Retrieve subscription preferences for a data subject. Returns the current subscription topic and control statuses. + + #### Input | Parameter | Type | Required | Description | @@ -88,10 +99,14 @@ Retrieve subscription preferences for a data subject. Returns the current subscr | `topics` | object | Map of topic codes to contact method settings \(e.g., \{"newsletter": \{"email": \{"status": "granted"\}\}\}\) | | `controls` | object | Map of control codes to settings \(e.g., \{"global_unsubscribe": \{"status": "denied"\}\}\) | + + ### Ketch Set Subscriptions Update subscription preferences for a data subject. Sets topic and control statuses for email, SMS, and other contact methods. + + #### Input | Parameter | Type | Required | Description | @@ -109,10 +124,14 @@ Update subscription preferences for a data subject. Sets topic and control statu | --------- | ---- | ----------- | | `success` | boolean | Whether the subscription preferences were updated | + + ### Ketch Invoke Right Submit a data subject rights request (e.g., access, delete, correct, restrict processing). Initiates a privacy rights workflow in Ketch. + + #### Input | Parameter | Type | Required | Description | @@ -132,4 +151,6 @@ Submit a data subject rights request (e.g., access, delete, correct, restrict pr | `success` | boolean | Whether the rights request was submitted | | `message` | string | Response message from Ketch | + + diff --git a/apps/docs/content/docs/integrations/langsmith.mdx b/apps/docs/content/docs/integrations/langsmith.mdx index 93f69870a5e..9424423025c 100644 --- a/apps/docs/content/docs/integrations/langsmith.mdx +++ b/apps/docs/content/docs/integrations/langsmith.mdx @@ -62,6 +62,8 @@ Forward a single run to LangSmith for ingestion. Forward multiple runs to LangSmith in a single batch. + + #### Input | Parameter | Type | Required | Description | @@ -79,10 +81,14 @@ Forward multiple runs to LangSmith in a single batch. | `message` | string | Response message from LangSmith | | `messages` | array | Per-run response messages, when provided | + + ### LangSmith Update Run Patch an existing LangSmith run with outputs, status, or timing once it completes. + + #### Input | Parameter | Type | Required | Description | @@ -102,10 +108,14 @@ Patch an existing LangSmith run with outputs, status, or timing once it complete No output fields are documented for this action. + + ### LangSmith Get Run Retrieve a single LangSmith run by ID. + + #### Input | Parameter | Type | Required | Description | @@ -134,10 +144,14 @@ Retrieve a single LangSmith run by ID. | `totalTokens` | number | Total tokens consumed by the run | | `totalCost` | string | Total cost of the run | + + ### LangSmith Create Feedback Attach a score, correction, or comment to a LangSmith run. + + #### Input | Parameter | Type | Required | Description | @@ -163,4 +177,6 @@ Attach a score, correction, or comment to a LangSmith run. | `comment` | string | Comment recorded for the feedback | | `createdAt` | string | When the feedback was created \(ISO\) | + + diff --git a/apps/docs/content/docs/integrations/latex.mdx b/apps/docs/content/docs/integrations/latex.mdx index 41ee909f15e..e432a0eaa7a 100644 --- a/apps/docs/content/docs/integrations/latex.mdx +++ b/apps/docs/content/docs/integrations/latex.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [LaTeX](https://www.latex-project.org/) compiles plain-text document source into PDFs. Provide the source, select a compiler, and attach any required images, included files, or bibliographies. You can also inspect the available TeX Live packages and system fonts. No OAuth connection or API key is required. @@ -13,7 +15,6 @@ integration: Note: compilation runs on the public [LaTeX-on-HTTP](https://github.com/YtoTech/latex-on-http) service at latex.ytotech.com, so the document source and any attached resources are sent to that third-party service. Avoid compiling documents whose contents must not leave your environment. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Compiles LaTeX source into a PDF file with pdflatex, xelatex, lualatex, platex, uplatex, or context, and supports additional resources such as images, included .tex files, and bibliographies. Can also look up the TeX Live packages and system fonts available to the compiler. Does not require OAuth or an API key. Compilation runs on the public LaTeX-on-HTTP service (latex.ytotech.com), so document source and resources are sent to that third-party service. @@ -24,6 +25,8 @@ Compiles LaTeX source into a PDF file with pdflatex, xelatex, lualatex, platex, Compile a LaTeX document into a PDF via the public LaTeX-on-HTTP service (latex.ytotech.com). Supports pdflatex, xelatex, lualatex, platex, uplatex, and context, plus supporting resources such as images, included .tex files, and bibliographies. + + #### Input | Parameter | Type | Required | Description | @@ -42,10 +45,14 @@ Compile a LaTeX document into a PDF via the public LaTeX-on-HTTP service (latex. | `fileName` | string | Name of the compiled PDF file | | `compiler` | string | LaTeX compiler used for the build | + + ### LaTeX Search Packages Search the TeX Live packages available to the LaTeX compiler by name or description, e.g. to check which packages can be used in a document. + + #### Input | Parameter | Type | Required | Description | @@ -64,10 +71,14 @@ Search the TeX Live packages available to the LaTeX compiler by name or descript | ↳ `ctanUrl` | string | CTAN page for the package | | `totalMatches` | number | Total number of packages matching the query, before truncation | + + ### LaTeX Get Package Get details about a specific TeX Live package available to the LaTeX compiler, including whether it is installed, its description, license, and related packages. + + #### Input | Parameter | Type | Required | Description | @@ -90,10 +101,14 @@ Get details about a specific TeX Live package available to the LaTeX compiler, i | ↳ `homepage` | string | Package homepage URL | | ↳ `ctanUrl` | string | CTAN page for the package | + + ### LaTeX List Fonts List the system fonts available to the LaTeX compiler, optionally filtered by name, e.g. to pick a font for xelatex or lualatex documents using fontspec. + + #### Input | Parameter | Type | Required | Description | @@ -111,4 +126,6 @@ List the system fonts available to the LaTeX compiler, optionally filtered by na | ↳ `styles` | array | Available styles, e.g. Bold or Italic | | `totalMatches` | number | Total number of fonts matching the filter, before truncation | + + diff --git a/apps/docs/content/docs/integrations/lemlist.mdx b/apps/docs/content/docs/integrations/lemlist.mdx index de6e54986fb..b6477687b85 100644 --- a/apps/docs/content/docs/integrations/lemlist.mdx +++ b/apps/docs/content/docs/integrations/lemlist.mdx @@ -15,7 +15,6 @@ Use [Lemlist](https://lemlist.com) to retrieve campaign activities and replies, Triggers can start workflows from Lemlist activity events. Use activity data to summarize campaign performance, sync replies to a CRM, or decide which leads need follow-up. The block does not expose campaign creation or multi-channel sequence management. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Retrieve campaign activities and replies, get lead information, and send emails through the Lemlist inbox. @@ -26,6 +25,8 @@ Retrieve campaign activities and replies, get lead information, and send emails Retrieves campaign activities and steps performed, including email opens, clicks, replies, and other events. + + #### Input | Parameter | Type | Required | Description | @@ -52,10 +53,14 @@ Retrieves campaign activities and steps performed, including email opens, clicks | ↳ `createdAt` | string | When the activity occurred | | `count` | number | Number of activities returned | + + ### Lemlist Get Lead Retrieves lead information by email address or lead ID. + + #### Input | Parameter | Type | Required | Description | @@ -79,10 +84,14 @@ Retrieves lead information by email address or lead ID. | `contactId` | string | Contact ID | | `emailStatus` | string | Email deliverability status | + + ### Lemlist Send Email Sends an email to a contact through the Lemlist inbox. + + #### Input | Parameter | Type | Required | Description | @@ -103,6 +112,8 @@ Sends an email to a contact through the Lemlist inbox. | --------- | ---- | ----------- | | `ok` | boolean | Whether the email was sent successfully | + + ## Triggers diff --git a/apps/docs/content/docs/integrations/linkedin.mdx b/apps/docs/content/docs/integrations/linkedin.mdx index 41c39fb0108..40e12795d5a 100644 --- a/apps/docs/content/docs/integrations/linkedin.mdx +++ b/apps/docs/content/docs/integrations/linkedin.mdx @@ -6,12 +6,13 @@ integration: color: "#0072B1" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [LinkedIn](https://www.linkedin.com) to publish to your personal feed and retrieve your profile information from a workflow. {/* MANUAL-CONTENT-END */} - ## OAuth scopes [#oauth-scopes] Sim requests these scopes when someone connects a LinkedIn account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup. @@ -38,6 +39,8 @@ Share posts to your personal feed and access your LinkedIn profile information. Share a post to your personal LinkedIn feed + + #### Input | Parameter | Type | Required | Description | @@ -54,10 +57,14 @@ Share a post to your personal LinkedIn feed | `profile` | json | LinkedIn profile information | | `error` | string | Error message if operation failed | + + ### Get LinkedIn Profile Retrieve your LinkedIn profile information + + #### Input | Parameter | Type | Required | Description | @@ -72,4 +79,6 @@ Retrieve your LinkedIn profile information | `profile` | json | LinkedIn profile information | | `error` | string | Error message if operation failed | + + diff --git a/apps/docs/content/docs/integrations/linkup.mdx b/apps/docs/content/docs/integrations/linkup.mdx index 6c85519c93f..336b6427b6f 100644 --- a/apps/docs/content/docs/integrations/linkup.mdx +++ b/apps/docs/content/docs/integrations/linkup.mdx @@ -6,6 +6,8 @@ integration: color: "#D6D3C7" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Linkup](https://linkup.so) to search the web from a workflow or an agent's tool set. Choose the supported search depth and output type for your query; results can include source URLs for downstream citation. @@ -13,7 +15,6 @@ Use [Linkup](https://linkup.so) to search the web from a workflow or an agent's Supply your Linkup API key and query in the block. When using it as an agent tool, the agent decides when to call it based on its instructions. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Search the web with a Linkup API key, query, supported search depth, and output type. @@ -24,6 +25,8 @@ Search the web with a Linkup API key, query, supported search depth, and output Search the web for information using Linkup + + #### Input | Parameter | Type | Required | Description | @@ -47,4 +50,6 @@ Search the web for information using Linkup | `answer` | string | The sourced answer to the search query | | `sources` | array | Array of sources used to compile the answer, each containing name, url, and snippet | + + diff --git a/apps/docs/content/docs/integrations/linq.mdx b/apps/docs/content/docs/integrations/linq.mdx index 43dce8c689d..e47fbd52339 100644 --- a/apps/docs/content/docs/integrations/linq.mdx +++ b/apps/docs/content/docs/integrations/linq.mdx @@ -915,6 +915,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when a message is delivered + + #### Configuration | Parameter | Type | Required | Description | @@ -932,13 +934,14 @@ Trigger workflow when a message is delivered | `webhookVersion` | string | Payload schema version of the delivered event | | `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | - ---- + ### Linq Message Failed Trigger workflow when a message fails to deliver + + #### Configuration | Parameter | Type | Required | Description | @@ -956,13 +959,14 @@ Trigger workflow when a message fails to deliver | `webhookVersion` | string | Payload schema version of the delivered event | | `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | - ---- + ### Linq Message Read Trigger workflow when a message is read + + #### Configuration | Parameter | Type | Required | Description | @@ -980,13 +984,14 @@ Trigger workflow when a message is read | `webhookVersion` | string | Payload schema version of the delivered event | | `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | - ---- + ### Linq Message Received Trigger workflow when an inbound message is received + + #### Configuration | Parameter | Type | Required | Description | @@ -1004,13 +1009,14 @@ Trigger workflow when an inbound message is received | `webhookVersion` | string | Payload schema version of the delivered event | | `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | - ---- + ### Linq Reaction Added Trigger workflow when a reaction is added to a message + + #### Configuration | Parameter | Type | Required | Description | @@ -1028,13 +1034,14 @@ Trigger workflow when a reaction is added to a message | `webhookVersion` | string | Payload schema version of the delivered event | | `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | - ---- + ### Linq Webhook (All Events) Trigger on any Linq webhook event (messages, reactions, chats, and more) + + #### Configuration | Parameter | Type | Required | Description | @@ -1052,3 +1059,5 @@ Trigger on any Linq webhook event (messages, reactions, chats, and more) | `webhookVersion` | string | Payload schema version of the delivered event | | `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + + diff --git a/apps/docs/content/docs/integrations/logfire.mdx b/apps/docs/content/docs/integrations/logfire.mdx index aa7f669a350..6c6b3a0b056 100644 --- a/apps/docs/content/docs/integrations/logfire.mdx +++ b/apps/docs/content/docs/integrations/logfire.mdx @@ -27,6 +27,8 @@ Run SQL over your observability data, search spans and logs with structured filt Run a read-only SQL query against Logfire traces, logs, and metrics. Reads the records and metrics tables using PostgreSQL-compatible syntax. + + #### Input | Parameter | Type | Required | Description | @@ -52,6 +54,8 @@ Run a read-only SQL query against Logfire traces, logs, and metrics. Reads the r | ↳ `nullable` | boolean | Whether the column is nullable | | `rowCount` | number | Number of rows returned | + + ### Logfire Search Records Search Logfire spans and logs using structured filters for message text, service, span name, severity, environment, and exceptions. Returns the most recent matches first without writing SQL. @@ -147,6 +151,8 @@ Fetch every span and log belonging to a Logfire trace, ordered from earliest to Resolve which Logfire organization and project a read token belongs to. Useful for confirming a credential targets the expected project before querying it. + + #### Input | Parameter | Type | Required | Description | @@ -164,4 +170,6 @@ Resolve which Logfire organization and project a read token belongs to. Useful f | `expiresAt` | string | When the read token expires. Null when it never expires. | | `spendingCapReachedAt` | string | When the organization's spending cap was reached, which stops queries. Null when it has not been reached. | + + diff --git a/apps/docs/content/docs/integrations/logrocket.mdx b/apps/docs/content/docs/integrations/logrocket.mdx index 6c5b7273537..23cfb74e7c7 100644 --- a/apps/docs/content/docs/integrations/logrocket.mdx +++ b/apps/docs/content/docs/integrations/logrocket.mdx @@ -6,6 +6,8 @@ integration: color: "#764ABC" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [LogRocket](https://logrocket.com/) to request Galileo Highlights for a user's sessions, update user names, emails, and traits, retrieve JSON Lines export URLs, inspect the session-access audit log, and register releases for source-map decoding. @@ -15,7 +17,6 @@ Use [LogRocket](https://logrocket.com/) to request Galileo Highlights for a user For session, issue, and metric queries beyond this block, inspect LogRocket's [MCP server](https://docs.logrocket.com/docs/mcp) at `https://mcp.logrocket.com/mcp` and add it as a separate MCP connection in Sim. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Request AI session highlights for a user, poll for the result, list exported session files, read the audit log, create or update user profiles, and register releases so source maps decode their stack traces. @@ -26,6 +27,8 @@ Request AI session highlights for a user, poll for the result, list exported ses Start a Galileo session highlights job for a user. Returns a request ID to poll with LogRocket's Get Highlights operation. + + #### Input | Parameter | Type | Required | Description | @@ -47,10 +50,14 @@ Start a Galileo session highlights job for a user. Returns a request ID to poll | --------- | ---- | ----------- | | `id` | string | Request ID used to retrieve the highlights result | + + ### LogRocket Get Highlights Retrieve the result of a Galileo session highlights request. Status is PENDING until generation finishes, then READY or FAILED. + + #### Input | Parameter | Type | Required | Description | @@ -74,10 +81,14 @@ Retrieve the result of a Galileo session highlights request. Status is PENDING u | ↳ `sessionID` | number | Session number within the recording | | ↳ `highlights` | string | Highlights for this session | + + ### LogRocket List Exported Sessions List exported session files from the LogRocket Data Export API. Returns download URLs for JSON Lines exports, oldest first, plus a cursor for the next page. + + #### Input | Parameter | Type | Required | Description | @@ -98,10 +109,14 @@ List exported session files from the LogRocket Data Export API. Returns download | ↳ `url` | string | Download URL for the JSON Lines export file | | `cursor` | string | Opaque cursor for the next page of results | + + ### LogRocket Get Audit Logs Export audit log entries from LogRocket, recording who viewed sessions and what actions were taken. Paginated with a cursor. + + #### Input | Parameter | Type | Required | Description | @@ -126,10 +141,14 @@ Export audit log entries from LogRocket, recording who viewed sessions and what | `cursor` | string | Opaque cursor for the next page of results | | `hasNext` | boolean | Whether more audit logs exist beyond this page | + + ### LogRocket Identify User Create or update a LogRocket user profile with demographic, financial, and engagement traits. + + #### Input | Parameter | Type | Required | Description | @@ -153,10 +172,14 @@ Create or update a LogRocket user profile with demographic, financial, and engag | `email` | string | Email stored on the profile | | `traits` | json | Custom traits stored on the profile, with every value coerced to a string | + + ### LogRocket Create Release Register a release version in LogRocket so uploaded source maps can decode stack traces for it. Fails with a conflict if the version already exists. + + #### Input | Parameter | Type | Required | Description | @@ -173,4 +196,6 @@ Register a release version in LogRocket so uploaded source maps can decode stack | --------- | ---- | ----------- | | `version` | string | Release version that was registered | + + diff --git a/apps/docs/content/docs/integrations/logs.mdx b/apps/docs/content/docs/integrations/logs.mdx index d9f9ca40b01..6056fcc8838 100644 --- a/apps/docs/content/docs/integrations/logs.mdx +++ b/apps/docs/content/docs/integrations/logs.mdx @@ -6,12 +6,13 @@ integration: color: "#EAB308" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} The Logs block queries workflow run history in the current workspace. **Query Logs** filters runs and returns matching run IDs; **Get Run Details** retrieves one run’s status, trigger, timing, cost, final output, and trace spans. See [Using Logs in Workflows](/workflows/blocks/logs) for a query-and-inspect example. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Query workflow run logs in the current workspace with the same filters as the Logs page, returning matching run IDs. Fetch full details for a single run, including its trace spans. @@ -22,6 +23,8 @@ Query workflow run logs in the current workspace with the same filters as the Lo Query workflow run logs in the current workspace with the full Logs-page filter set. Returns matching run IDs. + + #### Input | Parameter | Type | Required | Description | @@ -47,10 +50,14 @@ Query workflow run logs in the current workspace with the full Logs-page filter | --------- | ---- | ----------- | | `runIds` | array | IDs of the runs matching the filters | + + ### Get Run Details Fetch details for a single workflow run by its run ID, including the full trace spans. + + #### Input | Parameter | Type | Required | Description | @@ -72,4 +79,6 @@ Fetch details for a single workflow run by its run ID, including the full trace | `traceSpans` | array | Full trace spans for the run | | `finalOutput` | json | Final output of the run | + + diff --git a/apps/docs/content/docs/integrations/mem0.mdx b/apps/docs/content/docs/integrations/mem0.mdx index e87b624af1f..74e3aebe6da 100644 --- a/apps/docs/content/docs/integrations/mem0.mdx +++ b/apps/docs/content/docs/integrations/mem0.mdx @@ -23,6 +23,8 @@ Add, search, and retrieve memories. Add memories to Mem0 for persistent storage and retrieval + + #### Input | Parameter | Type | Required | Description | @@ -39,10 +41,14 @@ Add memories to Mem0 for persistent storage and retrieval | `status` | string | Processing status returned by Mem0 | | `event_id` | string | Event ID for polling memory processing status | + + ### Search Memories Search for memories in Mem0 using semantic search + + #### Input | Parameter | Type | Required | Description | @@ -71,6 +77,8 @@ Search for memories in Mem0 using semantic search | ↳ `score` | number | Similarity score from vector search | | `ids` | array | Array of memory IDs found in the search results | + + ### Get Memories Retrieve memories from Mem0 by ID or filter criteria diff --git a/apps/docs/content/docs/integrations/memory.mdx b/apps/docs/content/docs/integrations/memory.mdx index 3d2cdb3ed8c..c26546766d3 100644 --- a/apps/docs/content/docs/integrations/memory.mdx +++ b/apps/docs/content/docs/integrations/memory.mdx @@ -6,12 +6,13 @@ integration: color: "#F64F9E" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use the Memory block to store and retrieve messages across workflow runs. Add messages with the same conversation identifier to append to an existing conversation, retrieve one conversation or all memories, and delete memories that are no longer needed. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Add, get a memory, get all memories, and delete memories. @@ -22,6 +23,8 @@ Add, get a memory, get all memories, and delete memories. Add a new memory to the database or append to existing memory with the same ID. + + #### Input | Parameter | Type | Required | Description | @@ -39,10 +42,14 @@ Add a new memory to the database or append to existing memory with the same ID. | `memories` | array | Array of memory objects including the new or updated memory | | `error` | string | Error message if operation failed | + + ### Get Memory Retrieve memory by conversationId. Returns matching memories. + + #### Input | Parameter | Type | Required | Description | @@ -59,10 +66,14 @@ Retrieve memory by conversationId. Returns matching memories. | `message` | string | Success or error message | | `error` | string | Error message if operation failed | + + ### Get All Memories Retrieve all memories from the database + + #### Input | Parameter | Type | Required | Description | @@ -77,10 +88,14 @@ Retrieve all memories from the database | `message` | string | Success or error message | | `error` | string | Error message if operation failed | + + ### Delete Memory Delete memories by conversationId. + + #### Input | Parameter | Type | Required | Description | @@ -96,4 +111,6 @@ Delete memories by conversationId. | `message` | string | Success or error message | | `error` | string | Error message if operation failed | + + diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json index c2be78df692..c5ffaf9a611 100644 --- a/apps/docs/content/docs/integrations/meta.json +++ b/apps/docs/content/docs/integrations/meta.json @@ -88,7 +88,9 @@ "fireflies", "fivetran", "flint", + "flow", "gamma", + "gauge", "github", "gitlab", "gmail", @@ -135,6 +137,7 @@ "instantly", "intercom", "intune", + "jama", "jina", "jira", "jira_service_management", diff --git a/apps/docs/content/docs/integrations/microsoft_teams.mdx b/apps/docs/content/docs/integrations/microsoft_teams.mdx index a18e58327e4..7b5cc1f9aee 100644 --- a/apps/docs/content/docs/integrations/microsoft_teams.mdx +++ b/apps/docs/content/docs/integrations/microsoft_teams.mdx @@ -577,6 +577,8 @@ Trigger workflows when the outgoing webhook is @mentioned in a Microsoft Teams c Trigger workflows from Microsoft Teams chat message notifications via Microsoft Graph subscriptions. Notifications share a seven-day deduplication window keyed by subscription and message ID, so later edits may not start a separate run. + + #### Configuration | Parameter | Type | Required | Description | @@ -596,3 +598,5 @@ Trigger workflows from Microsoft Teams chat message notifications via Microsoft | `created_at` | string | Message timestamp | | `attachments` | file[] | Uploaded attachments as files | + + diff --git a/apps/docs/content/docs/integrations/millionverifier.mdx b/apps/docs/content/docs/integrations/millionverifier.mdx index 58eddfa02b3..ced7af74ddd 100644 --- a/apps/docs/content/docs/integrations/millionverifier.mdx +++ b/apps/docs/content/docs/integrations/millionverifier.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use MillionVerifier to verify an email and check remaining verification credits. Results classify the address as ok, catch-all, unknown, invalid, disposable, or unverified, with role-account and free-provider flags. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Integrate MillionVerifier to verify email deliverability in real time — classify addresses as valid, invalid, catch-all, disposable, or unknown — and check your remaining verification credits. @@ -22,6 +23,8 @@ Integrate MillionVerifier to verify email deliverability in real time — classi Verify the deliverability of an email address. Uses one verification credit. + + #### Input | Parameter | Type | Required | Description | @@ -41,10 +44,14 @@ Verify the deliverability of an email address. Uses one verification credit. | `didYouMean` | string | Suggested correction for a likely typo | | `subResult` | string | Additional MillionVerifier classification detail | + + ### MillionVerifier Get Credits Retrieve the remaining verification credits for the authenticated account. + + #### Input | Parameter | Type | Required | Description | @@ -57,4 +64,6 @@ Retrieve the remaining verification credits for the authenticated account. | --------- | ---- | ----------- | | `credits` | number | Remaining verification credits | + + diff --git a/apps/docs/content/docs/integrations/modal.mdx b/apps/docs/content/docs/integrations/modal.mdx index 1d129716526..d92ee07274c 100644 --- a/apps/docs/content/docs/integrations/modal.mdx +++ b/apps/docs/content/docs/integrations/modal.mdx @@ -6,6 +6,8 @@ integration: color: "#000000" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Modal](https://modal.com/) to call a deployed Web Function or Server over HTTPS, generate chat completions from a model served on a Modal Endpoint, or list available models. @@ -13,7 +15,6 @@ Use [Modal](https://modal.com/) to call a deployed Web Function or Server over H Create a proxy auth token in the Modal workspace settings and provide its token ID and secret. The endpoint or function must already be deployed; the block invokes it rather than provisioning compute. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Invoke a deployed Web Function or Server over HTTPS with proxy-token auth, generate completions from a model served by a Modal Endpoint, and list the models a token can reach. @@ -24,6 +25,8 @@ Invoke a deployed Web Function or Server over HTTPS with proxy-token auth, gener Invoke a deployed Modal Web Function or Server over HTTPS + + #### Input | Parameter | Type | Required | Description | @@ -44,10 +47,14 @@ Invoke a deployed Modal Web Function or Server over HTTPS | `status` | number | HTTP status code of the response | | `headers` | json | Response headers as key-value pairs | + + ### Modal Chat Completion Generate a chat completion from a model served by a Modal Endpoint + + #### Input | Parameter | Type | Required | Description | @@ -74,10 +81,14 @@ Generate a chat completion from a model served by a Modal Endpoint | ↳ `completion_tokens` | number | Number of tokens in the completion | | ↳ `total_tokens` | number | Total number of tokens used | + + ### Modal List Models List the model IDs a Modal proxy token can reach on an endpoint + + #### Input | Parameter | Type | Required | Description | @@ -93,4 +104,6 @@ List the model IDs a Modal proxy token can reach on an endpoint | `models` | array | Models the token can reach on the endpoint | | `count` | number | Number of models returned | + + diff --git a/apps/docs/content/docs/integrations/mongodb.mdx b/apps/docs/content/docs/integrations/mongodb.mdx index 01dcdb6d5a2..3317de7a94e 100644 --- a/apps/docs/content/docs/integrations/mongodb.mdx +++ b/apps/docs/content/docs/integrations/mongodb.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [MongoDB](https://www.mongodb.com/) to query collections, insert one or more documents, update or delete documents selected by a filter, and run aggregation pipelines. Configure the database connection and collection for the selected operation. @@ -13,7 +15,6 @@ Use [MongoDB](https://www.mongodb.com/) to query collections, insert one or more The `mongodb_query`, `mongodb_insert`, `mongodb_update`, `mongodb_delete`, and `mongodb_execute` operations return data for later workflow steps. Check the write filter and single/multiple-document option before updating or deleting records. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Find, insert, update, delete, and aggregate data. @@ -24,6 +25,8 @@ Find, insert, update, delete, and aggregate data. Execute find operation on MongoDB collection + + #### Input | Parameter | Type | Required | Description | @@ -48,10 +51,14 @@ Execute find operation on MongoDB collection | `documents` | array | Array of documents returned from the query | | `documentCount` | number | Number of documents returned | + + ### MongoDB Insert Insert documents into MongoDB collection + + #### Input | Parameter | Type | Required | Description | @@ -75,10 +82,14 @@ Insert documents into MongoDB collection | `insertedId` | string | ID of inserted document \(single insert\) | | `insertedIds` | array | Array of inserted document IDs \(multiple insert\) | + + ### MongoDB Update Update documents in MongoDB collection + + #### Input | Parameter | Type | Required | Description | @@ -106,10 +117,14 @@ Update documents in MongoDB collection | `documentCount` | number | Total number of documents affected | | `insertedId` | string | ID of inserted document \(if upsert\) | + + ### MongoDB Delete Delete documents from MongoDB collection + + #### Input | Parameter | Type | Required | Description | @@ -133,10 +148,14 @@ Delete documents from MongoDB collection | `deletedCount` | number | Number of documents deleted | | `documentCount` | number | Total number of documents affected | + + ### MongoDB Execute Execute MongoDB aggregation pipeline + + #### Input | Parameter | Type | Required | Description | @@ -159,10 +178,14 @@ Execute MongoDB aggregation pipeline | `documents` | array | Array of documents returned from aggregation | | `documentCount` | number | Number of documents returned | + + ### MongoDB Introspect Introspect MongoDB database to list databases, collections, and indexes + + #### Input | Parameter | Type | Required | Description | @@ -183,4 +206,6 @@ Introspect MongoDB database to list databases, collections, and indexes | `databases` | array | Array of database names | | `collections` | array | Array of collection info with name, type, document count, and indexes | + + diff --git a/apps/docs/content/docs/integrations/mssql.mdx b/apps/docs/content/docs/integrations/mssql.mdx index 9c600ba7c7f..4ddf6d463d4 100644 --- a/apps/docs/content/docs/integrations/mssql.mdx +++ b/apps/docs/content/docs/integrations/mssql.mdx @@ -23,6 +23,8 @@ Query, insert, update, delete, execute raw T-SQL, and introspect schemas. Execute a SELECT query on a Microsoft SQL Server database + + #### Input | Parameter | Type | Required | Description | @@ -47,10 +49,14 @@ Execute a SELECT query on a Microsoft SQL Server database | `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | | `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | + + ### Microsoft SQL Server Insert Insert data into a Microsoft SQL Server table + + #### Input | Parameter | Type | Required | Description | @@ -76,10 +82,14 @@ Insert data into a Microsoft SQL Server table | `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | | `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | + + ### Microsoft SQL Server Update Update rows in a Microsoft SQL Server table + + #### Input | Parameter | Type | Required | Description | @@ -106,10 +116,14 @@ Update rows in a Microsoft SQL Server table | `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | | `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | + + ### Microsoft SQL Server Delete Delete rows from a Microsoft SQL Server table + + #### Input | Parameter | Type | Required | Description | @@ -135,10 +149,14 @@ Delete rows from a Microsoft SQL Server table | `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | | `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | + + ### Microsoft SQL Server Execute Execute a raw T-SQL statement on a Microsoft SQL Server database + + #### Input | Parameter | Type | Required | Description | @@ -163,6 +181,8 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database | `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | | `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | + + ### Microsoft SQL Server Introspect Introspect a Microsoft SQL Server schema to retrieve table structures, columns, keys, and indexes. Results only cover objects the login can see, so a low-privilege account returns a partial schema rather than an error diff --git a/apps/docs/content/docs/integrations/mysql.mdx b/apps/docs/content/docs/integrations/mysql.mdx index 5f0f1e383b8..f4624bec7da 100644 --- a/apps/docs/content/docs/integrations/mysql.mdx +++ b/apps/docs/content/docs/integrations/mysql.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [MySQL](https://www.mysql.com/) to query rows, insert or modify records, execute raw SQL, and inspect databases, tables, columns, keys, and indexes. Configure the host, port, database, credentials, and SSL mode for the target server. @@ -13,7 +15,6 @@ Use [MySQL](https://www.mysql.com/) to query rows, insert or modify records, exe Query returns rows and a row count. Insert, update, and delete operations accept the data or WHERE clause documented below. Raw SQL can also change schema; use a database account with the permissions the workflow needs. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Query, insert, update, delete, and execute raw SQL. @@ -24,6 +25,8 @@ Query, insert, update, delete, and execute raw SQL. Execute SELECT query on MySQL database + + #### Input | Parameter | Type | Required | Description | @@ -44,10 +47,14 @@ Execute SELECT query on MySQL database | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows returned | + + ### MySQL Insert Insert new record into MySQL database + + #### Input | Parameter | Type | Required | Description | @@ -69,10 +76,14 @@ Insert new record into MySQL database | `rows` | array | Array of inserted rows | | `rowCount` | number | Number of rows inserted | + + ### MySQL Update Update existing records in MySQL database + + #### Input | Parameter | Type | Required | Description | @@ -95,10 +106,14 @@ Update existing records in MySQL database | `rows` | array | Array of updated rows | | `rowCount` | number | Number of rows updated | + + ### MySQL Delete Delete records from MySQL database + + #### Input | Parameter | Type | Required | Description | @@ -120,10 +135,14 @@ Delete records from MySQL database | `rows` | array | Array of deleted rows | | `rowCount` | number | Number of rows deleted | + + ### MySQL Execute Execute raw SQL query on MySQL database + + #### Input | Parameter | Type | Required | Description | @@ -144,10 +163,14 @@ Execute raw SQL query on MySQL database | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows affected | + + ### MySQL Introspect Introspect MySQL database schema to retrieve table structures, columns, and relationships + + #### Input | Parameter | Type | Required | Description | @@ -167,4 +190,6 @@ Introspect MySQL database schema to retrieve table structures, columns, and rela | `tables` | array | Array of table schemas with columns, keys, and indexes | | `databases` | array | List of available databases on the server | + + diff --git a/apps/docs/content/docs/integrations/neverbounce.mdx b/apps/docs/content/docs/integrations/neverbounce.mdx index 853a9025aee..81f70a9b501 100644 --- a/apps/docs/content/docs/integrations/neverbounce.mdx +++ b/apps/docs/content/docs/integrations/neverbounce.mdx @@ -6,12 +6,13 @@ integration: color: "#064AF4" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use NeverBounce to verify an email address and read remaining paid and free verification credits. Results classify the address as valid, invalid, disposable, catch-all, or unknown and include role-account and free-provider flags. Treat catch-all and unknown results as inconclusive rather than confirmed delivery. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Integrate NeverBounce to verify email deliverability in real time — classify addresses as valid, invalid, catch-all, disposable, or unknown — and check your remaining verification credits. @@ -22,6 +23,8 @@ Integrate NeverBounce to verify email deliverability in real time — classify a Verify the deliverability of an email address. Uses one verification credit. + + #### Input | Parameter | Type | Required | Description | @@ -41,10 +44,14 @@ Verify the deliverability of an email address. Uses one verification credit. | `didYouMean` | string | Suggested correction for a likely typo | | `flags` | array | Raw NeverBounce flags for the address | + + ### NeverBounce Get Credits Retrieve the remaining paid and free verification credits for the account. + + #### Input | Parameter | Type | Required | Description | @@ -58,4 +65,6 @@ Retrieve the remaining paid and free verification credits for the account. | `credits` | number | Remaining paid verification credits | | `freeCredits` | number | Remaining free verification credits | + + diff --git a/apps/docs/content/docs/integrations/new_relic.mdx b/apps/docs/content/docs/integrations/new_relic.mdx index 544f3d818af..1f137bd3f51 100644 --- a/apps/docs/content/docs/integrations/new_relic.mdx +++ b/apps/docs/content/docs/integrations/new_relic.mdx @@ -25,6 +25,8 @@ Run NRQL queries, search monitored entities, fetch entity details, and record de Run a NRQL query against a New Relic account using NerdGraph. + + #### Input | Parameter | Type | Required | Description | @@ -42,10 +44,14 @@ Run a NRQL query against a New Relic account using NerdGraph. | `results` | array | NRQL result rows. Row fields depend on the query projection. | | `resultCount` | number | Number of NRQL result rows returned | + + ### New Relic Search Entities Search New Relic entities by name, GUID, domain type, tags, or reporting state. + + #### Input | Parameter | Type | Required | Description | @@ -73,10 +79,14 @@ Search New Relic entities by name, GUID, domain type, tags, or reporting state. | ↳ `values` | array | Tag values | | `nextCursor` | string | Cursor for the next page of results | + + ### New Relic Get Entity Fetch a New Relic entity by GUID. + + #### Input | Parameter | Type | Required | Description | @@ -100,6 +110,8 @@ Fetch a New Relic entity by GUID. | ↳ `key` | string | Tag key | | ↳ `values` | array | Tag values | + + ### New Relic Create Deployment Event Record a deployment change event in New Relic change tracking. diff --git a/apps/docs/content/docs/integrations/oracledb.mdx b/apps/docs/content/docs/integrations/oracledb.mdx index d897a80220b..5664ce6813f 100644 --- a/apps/docs/content/docs/integrations/oracledb.mdx +++ b/apps/docs/content/docs/integrations/oracledb.mdx @@ -48,6 +48,8 @@ Query, modify, and inspect Oracle Database over Oracle Net using TCP or verified Run one read-only Oracle SELECT statement or ordinary SELECT CTE with optional named IN binds + + #### Input | Parameter | Type | Required | Description | @@ -67,10 +69,14 @@ Run one read-only Oracle SELECT statement or ordinary SELECT CTE with optional n | `truncated` | boolean | True when rows were dropped to stay inside the row or byte ceiling | | `truncationReason` | string | The response ceiling that was reached | + + ### Oracle Database Insert Insert one JSON object as a row in an Oracle Database table using bound values + + #### Input | Parameter | Type | Required | Description | @@ -91,10 +97,14 @@ Insert one JSON object as a row in an Oracle Database table using bound values | `truncated` | boolean | True when rows were dropped to stay inside the row or byte ceiling | | `truncationReason` | string | The response ceiling that was reached | + + ### Oracle Database Update Update Oracle Database rows selected by a required WHERE expression, using bound values for changed columns + + #### Input | Parameter | Type | Required | Description | @@ -116,10 +126,14 @@ Update Oracle Database rows selected by a required WHERE expression, using bound | `truncated` | boolean | True when rows were dropped to stay inside the row or byte ceiling | | `truncationReason` | string | The response ceiling that was reached | + + ### Oracle Database Delete Delete Oracle Database rows selected by a required WHERE expression + + #### Input | Parameter | Type | Required | Description | @@ -140,10 +154,14 @@ Delete Oracle Database rows selected by a required WHERE expression | `truncated` | boolean | True when rows were dropped to stay inside the row or byte ceiling | | `truncationReason` | string | The response ceiling that was reached | + + ### Oracle Database Execute Execute one allowed Oracle SQL statement: SELECT, INSERT, UPDATE, DELETE, MERGE, CREATE, ALTER, DROP, or EXPLAIN PLAN + + #### Input | Parameter | Type | Required | Description | @@ -163,6 +181,8 @@ Execute one allowed Oracle SQL statement: SELECT, INSERT, UPDATE, DELETE, MERGE, | `truncated` | boolean | True when rows were dropped to stay inside the row or byte ceiling | | `truncationReason` | string | The response ceiling that was reached | + + ### Oracle Database Introspect Inspect accessible Oracle tables, columns, keys, non-primary-key indexes, and table-owning schemas using ALL_* dictionary views diff --git a/apps/docs/content/docs/integrations/parallel_ai.mdx b/apps/docs/content/docs/integrations/parallel_ai.mdx index 338eb5b8faa..402e3e34543 100644 --- a/apps/docs/content/docs/integrations/parallel_ai.mdx +++ b/apps/docs/content/docs/integrations/parallel_ai.mdx @@ -6,12 +6,13 @@ integration: color: "#1D1C1A" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Parallel AI](https://parallel.ai/) to search the web, extract content from URLs, or start a deep-research task. Search accepts an objective and query controls, while extraction accepts the target URLs. Set the available result and content limits to keep downstream inputs bounded, and connect a Parallel API key. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Search the web, extract information from URLs, and conduct deep research. @@ -22,6 +23,8 @@ Search the web, extract information from URLs, and conduct deep research. Search the web with Parallel AI and return matching pages with content excerpts. + + #### Input | Parameter | Type | Required | Description | @@ -46,10 +49,14 @@ Search the web with Parallel AI and return matching pages with content excerpts. | ↳ `publish_date` | string | Publication date of the page \(YYYY-MM-DD\) | | ↳ `excerpts` | array | LLM-optimized excerpts from the page | + + ### Parallel AI Extract Extract targeted information from specific URLs using Parallel AI. Processes provided URLs to pull relevant content based on your objective. + + #### Input | Parameter | Type | Required | Description | @@ -76,10 +83,14 @@ Extract targeted information from specific URLs using Parallel AI. Processes pro | ↳ `http_status_code` | number | HTTP status returned by the page, if any | | ↳ `content` | string | Error detail | + + ### Parallel AI Deep Research Research the web with Parallel AI and synthesize findings from multiple sources with citations. Can take up to 45 minutes to complete. + + #### Input | Parameter | Type | Required | Description | @@ -108,4 +119,6 @@ Research the web with Parallel AI and synthesize findings from multiple sources | ↳ `excerpts` | array | Relevant excerpts from the source | | ↳ `confidence` | string | Confidence level \(low, medium, high\) | + + diff --git a/apps/docs/content/docs/integrations/perplexity.mdx b/apps/docs/content/docs/integrations/perplexity.mdx index 3f7dd63ef3d..78cb601a226 100644 --- a/apps/docs/content/docs/integrations/perplexity.mdx +++ b/apps/docs/content/docs/integrations/perplexity.mdx @@ -6,12 +6,13 @@ integration: color: "#20808D" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Perplexity](https://www.perplexity.ai) to generate chat responses with web citations or search the web with filtering. Pass the returned content and source references to downstream blocks as needed. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Generate completions using Perplexity AI chat models or perform web searches with advanced filtering. @@ -22,6 +23,8 @@ Generate completions using Perplexity AI chat models or perform web searches wit Generate completions using Perplexity AI chat models + + #### Input | Parameter | Type | Required | Description | @@ -44,10 +47,14 @@ Generate completions using Perplexity AI chat models | ↳ `completion_tokens` | number | Number of tokens in the completion | | ↳ `total_tokens` | number | Total number of tokens used | + + ### Perplexity Search Get ranked search results from Perplexity's continuously refreshed index with advanced filtering and customization options + + #### Input | Parameter | Type | Required | Description | @@ -73,4 +80,6 @@ Get ranked search results from Perplexity's continuously refreshed index with ad | ↳ `date` | string | Date the page was crawled and added to Perplexity's index | | ↳ `last_updated` | string | Date the page was last updated in Perplexity's index | + + diff --git a/apps/docs/content/docs/integrations/postgresql.mdx b/apps/docs/content/docs/integrations/postgresql.mdx index dfb4fa61470..c5ffbc47a8f 100644 --- a/apps/docs/content/docs/integrations/postgresql.mdx +++ b/apps/docs/content/docs/integrations/postgresql.mdx @@ -23,6 +23,8 @@ Query or modify PostgreSQL rows, execute SQL, and inspect tables, columns, keys, Execute a SELECT query on PostgreSQL database + + #### Input | Parameter | Type | Required | Description | @@ -43,10 +45,14 @@ Execute a SELECT query on PostgreSQL database | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows returned | + + ### PostgreSQL Insert Insert data into PostgreSQL database + + #### Input | Parameter | Type | Required | Description | @@ -68,10 +74,14 @@ Insert data into PostgreSQL database | `rows` | array | Inserted data \(if RETURNING clause used\) | | `rowCount` | number | Number of rows inserted | + + ### PostgreSQL Update Update data in PostgreSQL database + + #### Input | Parameter | Type | Required | Description | @@ -94,10 +104,14 @@ Update data in PostgreSQL database | `rows` | array | Updated data \(if RETURNING clause used\) | | `rowCount` | number | Number of rows updated | + + ### PostgreSQL Delete Delete data from PostgreSQL database + + #### Input | Parameter | Type | Required | Description | @@ -119,10 +133,14 @@ Delete data from PostgreSQL database | `rows` | array | Deleted data \(if RETURNING clause used\) | | `rowCount` | number | Number of rows deleted | + + ### PostgreSQL Execute Execute raw SQL query on PostgreSQL database + + #### Input | Parameter | Type | Required | Description | @@ -143,6 +161,8 @@ Execute raw SQL query on PostgreSQL database | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows affected | + + ### PostgreSQL Introspect Introspect PostgreSQL database schema to retrieve table structures, columns, and relationships diff --git a/apps/docs/content/docs/integrations/pulse.mdx b/apps/docs/content/docs/integrations/pulse.mdx index 0c89da9c7c9..b91ebfc1e12 100644 --- a/apps/docs/content/docs/integrations/pulse.mdx +++ b/apps/docs/content/docs/integrations/pulse.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Pulse](https://www.runpulse.com) to extract text and structured content from PDFs, images, and Office files with optical character recognition (OCR). Supply a file URL or upload, select pages when needed, and choose the supported Markdown, text, or JSON output options. @@ -13,7 +15,6 @@ Use [Pulse](https://www.runpulse.com) to extract text and structured content fro Optional figure and table extraction returns captions and descriptions. The result also includes processing metadata such as file type, page count, and processing time. Pass the extracted content to later steps for review or summarization. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Extract text from PDF documents, images, and Office files via upload or file references. @@ -24,6 +25,8 @@ Extract text from PDF documents, images, and Office files via upload or file ref Parse documents (PDF, images, Office docs) using Pulse OCR API + + #### Input | Parameter | Type | Required | Description | @@ -48,4 +51,6 @@ Parse documents (PDF, images, Office docs) using Pulse OCR API | `chunks` | json | Chunked content if chunking was enabled | | `figures` | json | Extracted figures; returned only when the hidden extractFigure input is enabled | + + diff --git a/apps/docs/content/docs/integrations/qdrant.mdx b/apps/docs/content/docs/integrations/qdrant.mdx index 45b9131cfef..cb3023fb45f 100644 --- a/apps/docs/content/docs/integrations/qdrant.mdx +++ b/apps/docs/content/docs/integrations/qdrant.mdx @@ -6,12 +6,13 @@ integration: color: "#1A223F" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Qdrant](https://qdrant.tech) in Sim to upsert points, search for similar vectors with optional payload filters, and fetch points by ID. Points contain vectors and associated payload data; fetch and search options control which fields are returned. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Upsert, search, and fetch points. @@ -22,6 +23,8 @@ Upsert, search, and fetch points. Insert or update points in a Qdrant collection + + #### Input | Parameter | Type | Required | Description | @@ -40,10 +43,14 @@ Insert or update points in a Qdrant collection | ↳ `operation_id` | number | Operation ID for async tracking | | ↳ `status` | string | Operation status \(acknowledged, completed\) | + + ### Qdrant Search Vector Search for similar vectors in a Qdrant collection + + #### Input | Parameter | Type | Required | Description | @@ -72,10 +79,14 @@ Search for similar vectors in a Qdrant collection | ↳ `shard_key` | string | Shard key for routing | | ↳ `order_value` | number | Order value for sorting | + + ### Qdrant Fetch Points Fetch points by ID from a Qdrant collection + + #### Input | Parameter | Type | Required | Description | @@ -100,4 +111,6 @@ Fetch points by ID from a Qdrant collection | ↳ `shard_key` | string | Shard key for routing | | ↳ `order_value` | number | Order value for sorting | + + diff --git a/apps/docs/content/docs/integrations/quiver.mdx b/apps/docs/content/docs/integrations/quiver.mdx index 35259abd222..337da479f53 100644 --- a/apps/docs/content/docs/integrations/quiver.mdx +++ b/apps/docs/content/docs/integrations/quiver.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [QuiverAI](https://quiver.ai/) to generate SVG graphics from text or vectorize a raster image. Supply up to four reference images for generation, and configure temperature, output count, and token limits where supported. @@ -13,7 +15,6 @@ Use [QuiverAI](https://quiver.ai/) to generate SVG graphics from text or vectori **List Models** reports available models and capabilities. Generated SVG markup and stored files are returned for downstream processing, storage, or delivery. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Generate SVG images from text prompts or vectorize raster images into SVGs using QuiverAI. Supports reference images, style instructions, and multiple output generation. @@ -24,6 +25,8 @@ Generate SVG images from text prompts or vectorize raster images into SVGs using Generate SVG images from text prompts using QuiverAI + + #### Input | Parameter | Type | Required | Description | @@ -50,10 +53,14 @@ Generate SVG images from text prompts using QuiverAI | ↳ `inputTokens` | number | Input tokens used | | ↳ `outputTokens` | number | Output tokens used | + + ### Quiver Image to SVG Convert raster images into vector SVG format using QuiverAI + + #### Input | Parameter | Type | Required | Description | @@ -79,10 +86,14 @@ Convert raster images into vector SVG format using QuiverAI | ↳ `inputTokens` | number | Input tokens used | | ↳ `outputTokens` | number | Output tokens used | + + ### Quiver List Models List all available QuiverAI models + + #### Input | Parameter | Type | Required | Description | @@ -108,4 +119,6 @@ List all available QuiverAI models | ↳ `supportedOperations` | json | Available operations \(svg_generate, svg_edit, svg_animate, svg_vectorize, chat_completions\) | | ↳ `supportedSamplingParameters` | json | Supported sampling parameters \(temperature, top_p, top_k, repetition_penalty, presence_penalty, stop\) | + + diff --git a/apps/docs/content/docs/integrations/rds.mdx b/apps/docs/content/docs/integrations/rds.mdx index c550cd67fc1..e1c7aa78bcd 100644 --- a/apps/docs/content/docs/integrations/rds.mdx +++ b/apps/docs/content/docs/integrations/rds.mdx @@ -6,12 +6,13 @@ integration: color: "linear-gradient(45deg, #2E27AD 0%, #527FFF 100%)" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Amazon RDS Aurora Serverless](https://aws.amazon.com/rds/aurora/serverless/) through the Data API to query or modify rows, execute SQL, and inspect the database schema from a workflow. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Integrate Amazon RDS Aurora Serverless into the workflow using the Data API. Can query, insert, update, delete, and execute raw SQL without managing database connections. @@ -22,6 +23,8 @@ Integrate Amazon RDS Aurora Serverless into the workflow using the Data API. Can Execute a SELECT query on Amazon RDS using the Data API + + #### Input | Parameter | Type | Required | Description | @@ -42,10 +45,14 @@ Execute a SELECT query on Amazon RDS using the Data API | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows returned | + + ### RDS Insert Insert data into an Amazon RDS table using the Data API + + #### Input | Parameter | Type | Required | Description | @@ -67,10 +74,14 @@ Insert data into an Amazon RDS table using the Data API | `rows` | array | Array of inserted rows | | `rowCount` | number | Number of rows inserted | + + ### RDS Update Update data in an Amazon RDS table using the Data API + + #### Input | Parameter | Type | Required | Description | @@ -93,10 +104,14 @@ Update data in an Amazon RDS table using the Data API | `rows` | array | Array of updated rows | | `rowCount` | number | Number of rows updated | + + ### RDS Delete Delete data from an Amazon RDS table using the Data API + + #### Input | Parameter | Type | Required | Description | @@ -118,10 +133,14 @@ Delete data from an Amazon RDS table using the Data API | `rows` | array | Array of deleted rows | | `rowCount` | number | Number of rows deleted | + + ### RDS Execute Execute raw SQL on Amazon RDS using the Data API + + #### Input | Parameter | Type | Required | Description | @@ -142,10 +161,14 @@ Execute raw SQL on Amazon RDS using the Data API | `rows` | array | Array of rows returned or affected | | `rowCount` | number | Number of rows affected | + + ### RDS Introspect Introspect Amazon RDS Aurora database schema to retrieve table structures, columns, and relationships + + #### Input | Parameter | Type | Required | Description | @@ -168,4 +191,6 @@ Introspect Amazon RDS Aurora database schema to retrieve table structures, colum | `tables` | array | Array of table schemas with columns, keys, and indexes | | `schemas` | array | List of available schemas in the database | + + diff --git a/apps/docs/content/docs/integrations/reducto.mdx b/apps/docs/content/docs/integrations/reducto.mdx index 41a2b4bc5c1..f22dbf6bc94 100644 --- a/apps/docs/content/docs/integrations/reducto.mdx +++ b/apps/docs/content/docs/integrations/reducto.mdx @@ -6,12 +6,13 @@ integration: color: "#5c0c5c" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Reducto](https://reducto.ai/) to extract content from a PDF supplied as an upload or file reference. Optionally select 1-indexed pages and choose Markdown or HTML for table output. The result includes parsed chunks and blocks, job metadata, and usage information. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Extract text from uploaded PDF documents or file references. @@ -22,6 +23,8 @@ Extract text from uploaded PDF documents or file references. Parse PDF documents using Reducto OCR API + + #### Input | Parameter | Type | Required | Description | @@ -42,4 +45,6 @@ Parse PDF documents using Reducto OCR API | `pdf_url` | string | Storage URL of converted PDF | | `studio_link` | string | Link to Reducto studio interface | + + diff --git a/apps/docs/content/docs/integrations/salesforce.mdx b/apps/docs/content/docs/integrations/salesforce.mdx index 96c091005b2..754b7c3c70d 100644 --- a/apps/docs/content/docs/integrations/salesforce.mdx +++ b/apps/docs/content/docs/integrations/salesforce.mdx @@ -1266,6 +1266,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when a case status changes + + #### Configuration | Parameter | Type | Required | Description | @@ -1294,13 +1296,14 @@ Trigger workflow when a case status changes | `newStatus` | string | New case status | | `payload` | json | Full webhook payload | - ---- + ### Salesforce Opportunity Stage Changed Trigger workflow when an opportunity stage changes + + #### Configuration | Parameter | Type | Required | Description | @@ -1329,13 +1332,14 @@ Trigger workflow when an opportunity stage changes | `newStage` | string | New stage name | | `payload` | json | Full webhook payload | - ---- + ### Salesforce Record Created Trigger workflow when a Salesforce record is created + + #### Configuration | Parameter | Type | Required | Description | @@ -1362,13 +1366,14 @@ Trigger workflow when a Salesforce record is created | `changedFields` | json | Fields that were changed \(for update events\) | | `payload` | json | Full webhook payload | - ---- + ### Salesforce Record Deleted Trigger workflow when a Salesforce record is deleted + + #### Configuration | Parameter | Type | Required | Description | @@ -1395,13 +1400,14 @@ Trigger workflow when a Salesforce record is deleted | `changedFields` | json | Fields that were changed \(for update events\) | | `payload` | json | Full webhook payload | - ---- + ### Salesforce Record Updated Trigger workflow when a Salesforce record is updated + + #### Configuration | Parameter | Type | Required | Description | @@ -1428,13 +1434,14 @@ Trigger workflow when a Salesforce record is updated | `changedFields` | json | Fields that were changed \(for update events\) | | `payload` | json | Full webhook payload | - ---- + ### Salesforce Webhook (All Events) Trigger workflow on any Salesforce webhook event + + #### Configuration | Parameter | Type | Required | Description | @@ -1454,3 +1461,5 @@ Trigger workflow on any Salesforce webhook event | `record` | json | Full record data | | `payload` | json | Full webhook payload | + + diff --git a/apps/docs/content/docs/integrations/sendblue.mdx b/apps/docs/content/docs/integrations/sendblue.mdx index 9772a6a7659..3a65ce61b5f 100644 --- a/apps/docs/content/docs/integrations/sendblue.mdx +++ b/apps/docs/content/docs/integrations/sendblue.mdx @@ -127,6 +127,8 @@ Send an iMessage or SMS to a group of recipients via Sendblue. Check whether a phone number can receive iMessage or only SMS. + + #### Input | Parameter | Type | Required | Description | @@ -142,10 +144,14 @@ Check whether a phone number can receive iMessage or only SMS. | `number` | string | The evaluated phone number in E.164 format | | `service` | string | The service the number supports: iMessage or SMS | + + ### Sendblue Send Typing Indicator Display a typing indicator to a recipient (not supported in group chats). + + #### Input | Parameter | Type | Required | Description | @@ -166,6 +172,8 @@ Display a typing indicator to a recipient (not supported in group chats). | `number` | string | The recipient phone number | | `error_message` | string | Error details, null on success | + + ### Sendblue Get Message Retrieve a single message and its current status by message handle/ID. diff --git a/apps/docs/content/docs/integrations/sentry.mdx b/apps/docs/content/docs/integrations/sentry.mdx index 010ce8320fb..4c7120ad937 100644 --- a/apps/docs/content/docs/integrations/sentry.mdx +++ b/apps/docs/content/docs/integrations/sentry.mdx @@ -772,6 +772,8 @@ Trigger workflow when a new error event is created in Sentry Trigger workflow when a Sentry issue alert rule fires + + #### Configuration | Parameter | Type | Required | Description | @@ -791,8 +793,7 @@ Trigger workflow when a Sentry issue alert rule fires | ↳ `title` | string | Alert rule name | | ↳ `settings` | json | Alert rule action settings \(name/value pairs\) | - ---- + ### Sentry Issue Created @@ -918,6 +919,8 @@ Trigger workflow when an issue is resolved in Sentry Trigger workflow when a Sentry metric alert changes state (critical, warning, resolved) + + #### Configuration | Parameter | Type | Required | Description | @@ -936,3 +939,5 @@ Trigger workflow when a Sentry metric alert changes state (critical, warning, re | `description_title` | string | Human-friendly title of the alert | | `web_url` | string | API URL for the incident | + + diff --git a/apps/docs/content/docs/integrations/serper.mdx b/apps/docs/content/docs/integrations/serper.mdx index d60ee159a1d..52b112f879f 100644 --- a/apps/docs/content/docs/integrations/serper.mdx +++ b/apps/docs/content/docs/integrations/serper.mdx @@ -6,12 +6,13 @@ integration: color: "#2B3543" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} [Serper](https://serper.dev/) provides web search results for a query. Use the returned titles, snippets, and URLs as inputs to later workflow steps, and configure the search options listed below. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Search the web. @@ -22,6 +23,8 @@ Search the web. Search Google through the Serper.dev API for web, news, places, images, videos, shopping, scholar, or patent results. Available response sections include organic results, a knowledge graph, an answer box, related questions, related searches, and top stories. + + #### Input | Parameter | Type | Required | Description | @@ -51,4 +54,6 @@ Search Google through the Serper.dev API for web, news, places, images, videos, | ↳ `price` | string | Price \(shopping\) | | ↳ `duration` | string | Duration \(videos\) | + + diff --git a/apps/docs/content/docs/integrations/servicenow.mdx b/apps/docs/content/docs/integrations/servicenow.mdx index b25fb3cb501..91f21ff398e 100644 --- a/apps/docs/content/docs/integrations/servicenow.mdx +++ b/apps/docs/content/docs/integrations/servicenow.mdx @@ -1826,6 +1826,8 @@ Trigger workflow when an incident is updated in ServiceNow Trigger workflow on any ServiceNow webhook event + + #### Configuration | Parameter | Type | Required | Description | @@ -1854,3 +1856,5 @@ Trigger workflow on any ServiceNow webhook event | `category` | string | Record category | | `record` | json | Full record data from the webhook payload | + + diff --git a/apps/docs/content/docs/integrations/sftp.mdx b/apps/docs/content/docs/integrations/sftp.mdx index 800704930be..bbcc56e1ffa 100644 --- a/apps/docs/content/docs/integrations/sftp.mdx +++ b/apps/docs/content/docs/integrations/sftp.mdx @@ -6,6 +6,8 @@ integration: color: "#2D3748" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use SFTP (SSH File Transfer Protocol) to upload or download files, list directories with optional metadata, delete files or directories, and create directories on a remote server. Authenticate with a password or OpenSSH private key; provide a passphrase for an encrypted key. @@ -13,7 +15,6 @@ Use SFTP (SSH File Transfer Protocol) to upload or download files, list director Uploads accept workspace files or direct text content. The current Download operation returns a stored file reference for later workflow steps. Recursive deletion removes directory contents, so check the target path before enabling it. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers. @@ -24,6 +25,8 @@ Upload, download, list, and manage files on remote servers via SFTP. Supports bo Upload files to a remote SFTP server + + #### Input | Parameter | Type | Required | Description | @@ -49,10 +52,14 @@ Upload files to a remote SFTP server | `uploadedFiles` | json | Array of uploaded file details \(name, remotePath, size\) | | `message` | string | Operation status message | + + ### SFTP Download Download a file from a remote SFTP server + + #### Input | Parameter | Type | Required | Description | @@ -71,10 +78,14 @@ Download a file from a remote SFTP server | --------- | ---- | ----------- | | `file` | file | Downloaded file stored in execution files | + + ### SFTP List Directory List files and directories on a remote SFTP server + + #### Input | Parameter | Type | Required | Description | @@ -98,10 +109,14 @@ List files and directories on a remote SFTP server | `count` | number | Number of entries in the directory | | `message` | string | Operation status message | + + ### SFTP Delete Delete a file or directory on a remote SFTP server + + #### Input | Parameter | Type | Required | Description | @@ -123,10 +138,14 @@ Delete a file or directory on a remote SFTP server | `deletedPath` | string | Path that was deleted | | `message` | string | Operation status message | + + ### SFTP Create Directory Create a directory on a remote SFTP server + + #### Input | Parameter | Type | Required | Description | @@ -148,4 +167,6 @@ Create a directory on a remote SFTP server | `createdPath` | string | Path of the created directory | | `message` | string | Operation status message | + + diff --git a/apps/docs/content/docs/integrations/similarweb.mdx b/apps/docs/content/docs/integrations/similarweb.mdx index 007e7e1bb4f..cf74d3d35ca 100644 --- a/apps/docs/content/docs/integrations/similarweb.mdx +++ b/apps/docs/content/docs/integrations/similarweb.mdx @@ -65,6 +65,8 @@ Get website traffic, rankings, engagement, and traffic sources. Get total website visits over time (desktop and mobile combined) + + #### Input | Parameter | Type | Required | Description | @@ -89,10 +91,14 @@ Get total website visits over time (desktop and mobile combined) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `visits` | number | Number of visits | + + ### SimilarWeb Bounce Rate Get website bounce rate over time (desktop and mobile combined) + + #### Input | Parameter | Type | Required | Description | @@ -117,10 +123,14 @@ Get website bounce rate over time (desktop and mobile combined) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `bounceRate` | number | Bounce rate \(0-1\) | + + ### SimilarWeb Pages Per Visit Get average pages per visit over time (desktop and mobile combined) + + #### Input | Parameter | Type | Required | Description | @@ -145,10 +155,14 @@ Get average pages per visit over time (desktop and mobile combined) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `pagesPerVisit` | number | Average pages per visit | + + ### SimilarWeb Visit Duration Get average desktop visit duration over time (in seconds) + + #### Input | Parameter | Type | Required | Description | @@ -173,10 +187,14 @@ Get average desktop visit duration over time (in seconds) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `durationSeconds` | number | Average visit duration in seconds | + + ### SimilarWeb Page Views Get total page views over time (desktop and mobile combined) + + #### Input | Parameter | Type | Required | Description | @@ -201,4 +219,6 @@ Get total page views over time (desktop and mobile combined) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `pageViews` | number | Total page views | + + diff --git a/apps/docs/content/docs/integrations/sixtyfour.mdx b/apps/docs/content/docs/integrations/sixtyfour.mdx index 673d8cec96c..39dd2922e65 100644 --- a/apps/docs/content/docs/integrations/sixtyfour.mdx +++ b/apps/docs/content/docs/integrations/sixtyfour.mdx @@ -23,6 +23,8 @@ Find emails, phone numbers, and enrich lead or company data with contact informa Find phone numbers for a lead using Sixtyfour AI. + + #### Input | Parameter | Type | Required | Description | @@ -43,6 +45,8 @@ Find phone numbers for a lead using Sixtyfour AI. | `phone` | string | Phone number\(s\) found | | `linkedinUrl` | string | LinkedIn profile URL | + + ### Sixtyfour Find Email Find email addresses for a lead using Sixtyfour AI. @@ -86,6 +90,8 @@ Find email addresses for a lead using Sixtyfour AI. Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI. + + #### Input | Parameter | Type | Required | Description | @@ -104,10 +110,14 @@ Enrich lead information with contact details, social profiles, and company data | `references` | json | Source URLs and descriptions used for enrichment | | `confidenceScore` | number | Quality score for the returned data \(0-10\) | + + ### Sixtyfour Enrich Company Enrich company data with additional information and find associated people using Sixtyfour AI. + + #### Input | Parameter | Type | Required | Description | @@ -131,4 +141,6 @@ Enrich company data with additional information and find associated people using | `confidenceScore` | number | Quality score for the returned data \(0-10\) | | `orgChart` | json | Org chart returned when fullOrgChart is enabled | + + diff --git a/apps/docs/content/docs/integrations/smtp.mdx b/apps/docs/content/docs/integrations/smtp.mdx index a4860edb3b6..87eb4521a4d 100644 --- a/apps/docs/content/docs/integrations/smtp.mdx +++ b/apps/docs/content/docs/integrations/smtp.mdx @@ -6,12 +6,13 @@ integration: color: "#2D3748" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} The SMTP block sends email through a mail server using its hostname, port, security protocol, and credentials. Set the sender, recipients, subject, and plain-text or HTML body; add CC, BCC, reply-to, and attachments as needed. Inspect the returned send status and message ID, or the error if sending fails. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Send email through an SMTP server using its host, port, security protocol, and credentials. Configure recipients, message content, and optional attachments. @@ -22,6 +23,8 @@ Send email through an SMTP server using its host, port, security protocol, and c Send emails via SMTP server + + #### Input | Parameter | Type | Required | Description | @@ -52,4 +55,6 @@ Send emails via SMTP server | `subject` | string | Email subject | | `error` | string | Error message if sending failed | + + diff --git a/apps/docs/content/docs/integrations/stagehand.mdx b/apps/docs/content/docs/integrations/stagehand.mdx index ed7ccaa79f8..84680b2fc0e 100644 --- a/apps/docs/content/docs/integrations/stagehand.mdx +++ b/apps/docs/content/docs/integrations/stagehand.mdx @@ -23,6 +23,8 @@ Extract structured data from webpages or run an autonomous agent to perform task Extract structured data from a webpage using Stagehand + + #### Input | Parameter | Type | Required | Description | @@ -39,6 +41,8 @@ Extract structured data from a webpage using Stagehand | --------- | ---- | ----------- | | `data` | object | Extracted structured data matching the provided schema | + + ### Stagehand Agent Run an autonomous web agent to complete tasks and extract structured data diff --git a/apps/docs/content/docs/integrations/stripe.mdx b/apps/docs/content/docs/integrations/stripe.mdx index f01bd1a292f..df414b2abc2 100644 --- a/apps/docs/content/docs/integrations/stripe.mdx +++ b/apps/docs/content/docs/integrations/stripe.mdx @@ -3126,6 +3126,8 @@ A trigger starts a workflow when a supported event occurs in this service. Triggers when Stripe events occur (payments, subscriptions, invoices, etc.) + + #### Configuration | Parameter | Type | Required | Description | @@ -3147,3 +3149,5 @@ Triggers when Stripe events occur (payments, subscriptions, invoices, etc.) | `pending_webhooks` | number | Number of webhooks yet to be delivered for this event | | `request` | json | Information about the API request that triggered this event \(id, idempotency_key\) | + + diff --git a/apps/docs/content/docs/integrations/sts.mdx b/apps/docs/content/docs/integrations/sts.mdx index af17d5f6da0..3d3be97dae7 100644 --- a/apps/docs/content/docs/integrations/sts.mdx +++ b/apps/docs/content/docs/integrations/sts.mdx @@ -62,6 +62,8 @@ Assume an IAM role and receive temporary security credentials Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials + + #### Input | Parameter | Type | Required | Description | @@ -91,10 +93,14 @@ Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actio | `packedPolicySize` | number | Percentage of allowed policy size used | | `sourceIdentity` | string | Source identity set on the role session, if any | + + ### STS Assume Role With SAML Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials + + #### Input | Parameter | Type | Required | Description | @@ -125,10 +131,14 @@ Assume an IAM role using a SAML 2.0 authentication response from an enterprise i | `packedPolicySize` | number | Percentage of allowed policy size used | | `sourceIdentity` | string | Source identity set on the role session, if any | + + ### STS Get Caller Identity Get details about the IAM user or role whose credentials are used to call the API + + #### Input | Parameter | Type | Required | Description | @@ -145,10 +155,14 @@ Get details about the IAM user or role whose credentials are used to call the AP | `arn` | string | ARN of the calling entity | | `userId` | string | Unique identifier of the calling entity | + + ### STS Get Session Token Get temporary security credentials for an IAM user, optionally with MFA + + #### Input | Parameter | Type | Required | Description | @@ -169,10 +183,14 @@ Get temporary security credentials for an IAM user, optionally with MFA | `sessionToken` | string | Temporary session token | | `expiration` | string | Credential expiration timestamp | + + ### STS Get Access Key Info Get the AWS account ID associated with an access key + + #### Input | Parameter | Type | Required | Description | @@ -188,4 +206,6 @@ Get the AWS account ID associated with an access key | --------- | ---- | ----------- | | `account` | string | AWS account ID that owns the access key | + + diff --git a/apps/docs/content/docs/integrations/tavily.mdx b/apps/docs/content/docs/integrations/tavily.mdx index f8de904f51d..cc85ce655d0 100644 --- a/apps/docs/content/docs/integrations/tavily.mdx +++ b/apps/docs/content/docs/integrations/tavily.mdx @@ -73,6 +73,8 @@ Perform AI-powered web searches using Tavily's search API. Returns structured re Extract raw content from multiple web pages simultaneously using Tavily's extraction API. Supports basic and advanced extraction depths with detailed error reporting for failed URLs. + + #### Input | Parameter | Type | Required | Description | @@ -98,6 +100,8 @@ Extract raw content from multiple web pages simultaneously using Tavily's extrac | ↳ `error` | string | Error message describing why extraction failed | | `response_time` | number | Time taken for the extraction request in seconds | + + ### Tavily Crawl Systematically crawl and extract content from websites using Tavily's crawl API. Supports depth control, path filtering, domain restrictions, and natural language instructions for targeted crawling. @@ -142,6 +146,8 @@ Systematically crawl and extract content from websites using Tavily's crawl API. Discover and visualize website structure using Tavily's map API. Maps out all accessible URLs from a base URL with depth control, path filtering, and domain restrictions. + + #### Input | Parameter | Type | Required | Description | @@ -168,4 +174,6 @@ Discover and visualize website structure using Tavily's map API. Maps out all ac | `response_time` | number | Time taken for the map request in seconds | | `request_id` | string | Unique identifier for support reference | + + diff --git a/apps/docs/content/docs/integrations/textract.mdx b/apps/docs/content/docs/integrations/textract.mdx index 535fb7cdc81..f00beb21805 100644 --- a/apps/docs/content/docs/integrations/textract.mdx +++ b/apps/docs/content/docs/integrations/textract.mdx @@ -83,6 +83,8 @@ Parse documents using AWS Textract OCR and document analysis Extract structured invoice and receipt fields using AWS Textract AnalyzeExpense + + #### Input | Parameter | Type | Required | Description | @@ -110,10 +112,14 @@ Extract structured invoice and receipt fields using AWS Textract AnalyzeExpense | ↳ `pages` | number | Number of pages in the document | | `modelVersion` | string | Version of the AnalyzeExpense model used \(multi-page/async only\) | + + ### AWS Textract Analyze ID Extract identity document fields using AWS Textract AnalyzeID + + #### Input | Parameter | Type | Required | Description | @@ -143,4 +149,6 @@ Extract identity document fields using AWS Textract AnalyzeID | ↳ `pages` | number | Number of pages analyzed | | `modelVersion` | string | Version of the AnalyzeID model used for processing | + + diff --git a/apps/docs/content/docs/integrations/tiktok.mdx b/apps/docs/content/docs/integrations/tiktok.mdx index dcdc18ce82b..ec4d601ef92 100644 --- a/apps/docs/content/docs/integrations/tiktok.mdx +++ b/apps/docs/content/docs/integrations/tiktok.mdx @@ -6,12 +6,13 @@ integration: color: "#000000" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [TikTok](https://www.tiktok.com/) to read the authenticated creator's profile and videos, send a video to their inbox as a draft, and track publishing status. The creator reviews and publishes drafts in TikTok; this integration does not publish them unattended. Webhooks can notify workflows about post and authorization changes. {/* MANUAL-CONTENT-END */} - ## OAuth scopes [#oauth-scopes] Sim requests these scopes when someone connects a TikTok account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup. @@ -39,6 +40,8 @@ Get user profile information including follower counts and video statistics. Lis Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics. + + #### Input | Parameter | Type | Required | Description | @@ -62,10 +65,14 @@ Get the authenticated TikTok user profile information including display name, av | `videoCount` | number | Total number of public videos | | `avatarFile` | file | Downloadable copy of the profile avatar image \(largest available variant\), stored as a workflow file so it can be chained into file-consuming blocks \(e.g. attached to an email\). | + + ### TikTok List Videos Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination. + + #### Input | Parameter | Type | Required | Description | @@ -96,10 +103,14 @@ Get a list of the authenticated user's TikTok videos with cover images, titles, | `cursor` | number | Cursor for fetching the next page of results | | `hasMore` | boolean | Whether there are more videos to fetch | + + ### TikTok Query Videos Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details. + + #### Input | Parameter | Type | Required | Description | @@ -127,10 +138,14 @@ Query specific TikTok videos by their IDs to get fresh metadata including cover | ↳ `commentCount` | number | Number of comments | | ↳ `shareCount` | number | Number of shares | + + ### TikTok Upload Video Draft Send an uploaded video to the authenticated user's TikTok inbox for manual editing and posting. The user must open TikTok and tap the inbox notification to complete the post. Rate limit: 6 requests per minute per user. + + #### Input | Parameter | Type | Required | Description | @@ -143,10 +158,14 @@ Send an uploaded video to the authenticated user's TikTok inbox for manual editi | --------- | ---- | ----------- | | `publishId` | string | Unique identifier for tracking the draft status. Use this with the Get Post Status tool to check when the user has completed posting from their inbox. | + + ### TikTok Get Post Status Check the status of a video draft upload. Use the publishId returned from Upload Video Draft to track progress. + + #### Input | Parameter | Type | Required | Description | @@ -163,6 +182,8 @@ Check the status of a video draft upload. Use the publishId returned from Upload | `uploadedBytes` | number | Number of bytes uploaded to TikTok for FILE_UPLOAD posts | | `downloadedBytes` | number | Number of bytes TikTok reports as downloaded | + + ## Triggers @@ -173,6 +194,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger when a user deauthorizes your TikTok app + + #### Configuration | Parameter | Type | Required | Description | @@ -189,13 +212,14 @@ Trigger when a user deauthorizes your TikTok app | `clientKey` | string | TikTok app client_key that received the event | | `reason` | number | Revocation reason \(0 unknown, 1 user disconnect, 2 account deleted, 3 age change, 4 banned, 5 developer revoke\) | - ---- + ### TikTok Post Inbox Delivered Trigger when a draft notification is delivered to the creator inbox + + #### Configuration | Parameter | Type | Required | Description | @@ -213,13 +237,14 @@ Trigger when a draft notification is delivered to the creator inbox | `publishId` | string | Content Posting API publish_id | | `publishType` | string | Publish type \(e.g. DIRECT_POST, INBOX_SHARE\) | - ---- + ### TikTok Post No Longer Public Trigger when a post is no longer publicly viewable on TikTok + + #### Configuration | Parameter | Type | Required | Description | @@ -238,13 +263,14 @@ Trigger when a post is no longer publicly viewable on TikTok | `publishType` | string | Publish type \(e.g. DIRECT_POST, INBOX_SHARE\) | | `postId` | string | TikTok post_id when the post is publicly available | - ---- + ### TikTok Post Publicly Available Trigger when a published post becomes publicly viewable on TikTok + + #### Configuration | Parameter | Type | Required | Description | @@ -263,13 +289,14 @@ Trigger when a published post becomes publicly viewable on TikTok | `publishType` | string | Publish type \(e.g. DIRECT_POST, INBOX_SHARE\) | | `postId` | string | TikTok post_id when the post is publicly available | - ---- + ### TikTok Post Publish Complete Trigger when a TikTok Content Posting publish completes + + #### Configuration | Parameter | Type | Required | Description | @@ -287,13 +314,14 @@ Trigger when a TikTok Content Posting publish completes | `publishId` | string | Content Posting API publish_id | | `publishType` | string | Publish type \(e.g. DIRECT_POST, INBOX_SHARE\) | - ---- + ### TikTok Post Publish Failed Trigger when a TikTok Content Posting publish fails + + #### Configuration | Parameter | Type | Required | Description | @@ -312,3 +340,5 @@ Trigger when a TikTok Content Posting publish fails | `publishType` | string | Publish type \(e.g. DIRECT_POST, INBOX_SHARE\) | | `failReason` | string | Failure reason enum from TikTok when publishing fails | + + diff --git a/apps/docs/content/docs/integrations/twilio_sms.mdx b/apps/docs/content/docs/integrations/twilio_sms.mdx index bd2670d7025..f0d3e00b4b5 100644 --- a/apps/docs/content/docs/integrations/twilio_sms.mdx +++ b/apps/docs/content/docs/integrations/twilio_sms.mdx @@ -6,12 +6,13 @@ integration: color: "#F22F46" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Twilio SMS](https://www.twilio.com/en-us/sms) to send a text message from a workflow. Configure the Twilio credentials, sending number, recipient, and message body documented below. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Send SMS messages. @@ -22,6 +23,8 @@ Send SMS messages. Send text messages to single or multiple recipients using the Twilio API. + + #### Input | Parameter | Type | Required | Description | @@ -42,4 +45,6 @@ Send text messages to single or multiple recipients using the Twilio API. | `fromNumber` | string | Phone number message was sent from | | `toNumber` | string | Phone number message was sent to | + + diff --git a/apps/docs/content/docs/integrations/twilio_voice.mdx b/apps/docs/content/docs/integrations/twilio_voice.mdx index 0fa321c9d49..d6b3458e6fe 100644 --- a/apps/docs/content/docs/integrations/twilio_voice.mdx +++ b/apps/docs/content/docs/integrations/twilio_voice.mdx @@ -63,6 +63,8 @@ Make an outbound phone call using Twilio Voice API. Retrieve a list of calls made to and from an account. + + #### Input | Parameter | Type | Required | Description | @@ -87,10 +89,14 @@ Retrieve a list of calls made to and from an account. | `pageSize` | number | Number of calls per page | | `error` | string | Error message if retrieval failed | + + ### Twilio Voice Get Recording Retrieve call recording information and transcription (if enabled via TwiML). + + #### Input | Parameter | Type | Required | Description | @@ -121,6 +127,8 @@ Retrieve call recording information and transcription (if enabled via TwiML). | `transcriptionPriceUnit` | string | Currency of the transcription price | | `error` | string | Error message if retrieval failed | + + ## Triggers diff --git a/apps/docs/content/docs/integrations/wealthbox.mdx b/apps/docs/content/docs/integrations/wealthbox.mdx index ed78427b243..c1481d1d349 100644 --- a/apps/docs/content/docs/integrations/wealthbox.mdx +++ b/apps/docs/content/docs/integrations/wealthbox.mdx @@ -6,6 +6,8 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Wealthbox](https://www.wealthbox.com/) to read and write client notes, contacts, and tasks. A workflow can turn meeting output into a note, retrieve client context, or create follow-up work. @@ -13,7 +15,6 @@ Use [Wealthbox](https://www.wealthbox.com/) to read and write client notes, cont Connect with OAuth or a supported [API access token](/integrations/wealthbox-service-account). Each credential uses the connected Wealthbox user's permissions. Contact writes create new contacts; note and task writes also support updates. {/* MANUAL-CONTENT-END */} - Connection guides: [API access tokens](/integrations/wealthbox-service-account). ## OAuth scopes [#oauth-scopes] @@ -40,6 +41,8 @@ Read or write Wealthbox notes, contacts, and tasks with OAuth or a supported API Read content from a Wealthbox note + + #### Input | Parameter | Type | Required | Description | @@ -59,10 +62,14 @@ Read content from a Wealthbox note | ↳ `noteId` | string | ID of the note | | ↳ `itemType` | string | Type of item \(note\) | + + ### Write Wealthbox Note Create or update a Wealthbox note + + #### Input | Parameter | Type | Required | Description | @@ -83,10 +90,14 @@ Create or update a Wealthbox note | ↳ `noteId` | string | ID of the created/updated note | | ↳ `itemType` | string | Type of item \(note\) | + + ### Read Wealthbox Contact Read content from a Wealthbox contact + + #### Input | Parameter | Type | Required | Description | @@ -106,10 +117,14 @@ Read content from a Wealthbox contact | ↳ `contactId` | string | ID of the contact | | ↳ `itemType` | string | Type of item \(contact\) | + + ### Write Wealthbox Contact Create a new Wealthbox contact + + #### Input | Parameter | Type | Required | Description | @@ -132,10 +147,14 @@ Create a new Wealthbox contact | ↳ `contactId` | string | ID of the created/updated contact | | ↳ `itemType` | string | Type of item \(contact\) | + + ### Read Wealthbox Task Read content from a Wealthbox task + + #### Input | Parameter | Type | Required | Description | @@ -155,10 +174,14 @@ Read content from a Wealthbox task | ↳ `taskId` | string | ID of the task | | ↳ `itemType` | string | Type of item \(task\) | + + ### Write Wealthbox Task Create or update a Wealthbox task + + #### Input | Parameter | Type | Required | Description | @@ -181,4 +204,6 @@ Create or update a Wealthbox task | ↳ `taskId` | string | ID of the created/updated task | | ↳ `itemType` | string | Type of item \(task\) | + + diff --git a/apps/docs/content/docs/integrations/webflow.mdx b/apps/docs/content/docs/integrations/webflow.mdx index c300b87fae0..f557bf5a8b2 100644 --- a/apps/docs/content/docs/integrations/webflow.mdx +++ b/apps/docs/content/docs/integrations/webflow.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use [Webflow](https://webflow.com/) in Sim to create, retrieve, list, update, and delete CMS collection items. Webflow event triggers can start workflows when collection items change or forms are submitted. See [Site Tokens](/integrations/webflow-service-account) for token setup. {/* MANUAL-CONTENT-END */} - Connection guides: [Site tokens](/integrations/webflow-service-account). ## OAuth scopes [#oauth-scopes] @@ -41,6 +42,8 @@ Create, get, list, update, or delete items in Webflow CMS collections. Manage yo List items from a Webflow CMS collection + + #### Input | Parameter | Type | Required | Description | @@ -69,10 +72,14 @@ List items from a Webflow CMS collection | ↳ `limit` | number | Maximum items per page | | ↳ `total` | number | Total number of matching items | + + ### Webflow Get Item Get a single item from a Webflow CMS collection + + #### Input | Parameter | Type | Required | Description | @@ -88,10 +95,14 @@ Get a single item from a Webflow CMS collection | `item` | json | The retrieved item object | | `metadata` | json | Metadata about the retrieved item | + + ### Webflow Create Item Create a new item in a Webflow CMS collection + + #### Input | Parameter | Type | Required | Description | @@ -107,10 +118,14 @@ Create a new item in a Webflow CMS collection | `item` | json | The created item object | | `metadata` | json | Metadata about the created item | + + ### Webflow Update Item Update an existing item in a Webflow CMS collection + + #### Input | Parameter | Type | Required | Description | @@ -127,10 +142,14 @@ Update an existing item in a Webflow CMS collection | `item` | json | The updated item object | | `metadata` | json | Metadata about the updated item | + + ### Webflow Delete Item Delete an item from a Webflow CMS collection + + #### Input | Parameter | Type | Required | Description | @@ -146,6 +165,8 @@ Delete an item from a Webflow CMS collection | `success` | boolean | Whether the deletion was successful | | `metadata` | json | Metadata about the deletion | + + ## Triggers @@ -156,6 +177,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger workflow when an item is updated in a Webflow CMS collection (requires Webflow credentials) + + #### Configuration | Parameter | Type | Required | Description | @@ -180,13 +203,14 @@ Trigger workflow when an item is updated in a Webflow CMS collection (requires W | ↳ `isDraft` | boolean | Whether the item is a draft | | ↳ `fieldData` | object | The updated field data of the item | - ---- + ### Collection Item Created Trigger workflow when a new item is created in a Webflow CMS collection (requires Webflow credentials) + + #### Configuration | Parameter | Type | Required | Description | @@ -211,13 +235,14 @@ Trigger workflow when a new item is created in a Webflow CMS collection (require | ↳ `isDraft` | boolean | Whether the item is a draft | | ↳ `fieldData` | object | The field data of the item | - ---- + ### Collection Item Deleted Trigger workflow when an item is deleted from a Webflow CMS collection (requires Webflow credentials) + + #### Configuration | Parameter | Type | Required | Description | @@ -236,13 +261,14 @@ Trigger workflow when an item is deleted from a Webflow CMS collection (requires | ↳ `id` | string | The ID of the deleted item | | ↳ `deletedOn` | string | Timestamp when the item was deleted | - ---- + ### Form Submission Trigger workflow when a form is submitted on a Webflow site (requires Webflow credentials) + + #### Configuration | Parameter | Type | Required | Description | @@ -267,3 +293,5 @@ Trigger workflow when a form is submitted on a Webflow site (requires Webflow cr | ↳ `fieldElementId` | string | Unique identifier for the form element \(UUID\) | | `formElementId` | string | The form element ID | + + diff --git a/apps/docs/content/docs/integrations/wikipedia.mdx b/apps/docs/content/docs/integrations/wikipedia.mdx index 0da74c78e12..82873f3f832 100644 --- a/apps/docs/content/docs/integrations/wikipedia.mdx +++ b/apps/docs/content/docs/integrations/wikipedia.mdx @@ -76,6 +76,8 @@ Get a summary and metadata for a specific Wikipedia page. Search for Wikipedia pages by title or content. + + #### Input | Parameter | Type | Required | Description | @@ -105,10 +107,14 @@ Search for Wikipedia pages by title or content. | `totalHits` | number | Total number of search results found | | `query` | string | The search query that was executed | + + ### Wikipedia Page Content Get the full HTML content of a Wikipedia page. + + #### Input | Parameter | Type | Required | Description | @@ -129,10 +135,14 @@ Get the full HTML content of a Wikipedia page. | ↳ `content_model` | string | Content model \(wikitext\) | | ↳ `content_format` | string | Content format \(text/html\) | + + ### Wikipedia Random Page Get a random Wikipedia page. + + #### Input | Parameter | Type | Required | Description | @@ -161,4 +171,6 @@ Get a random Wikipedia page. | ↳ `timestamp` | string | Timestamp | | ↳ `pageid` | number | Page ID | + + diff --git a/apps/docs/content/docs/integrations/wiza.mdx b/apps/docs/content/docs/integrations/wiza.mdx index aa7f1f1fdd1..3c8f25b16f8 100644 --- a/apps/docs/content/docs/integrations/wiza.mdx +++ b/apps/docs/content/docs/integrations/wiza.mdx @@ -202,6 +202,8 @@ Reveal a contact by LinkedIn URL, name plus company/domain, or email. Starts the Retrieve the remaining credits on your Wiza account + + #### Input | Parameter | Type | Required | Description | @@ -217,4 +219,6 @@ Retrieve the remaining credits on your Wiza account | `export_credits` | number | Remaining export credits | | `api_credits` | number | Remaining API credits | + + diff --git a/apps/docs/content/docs/integrations/zerobounce.mdx b/apps/docs/content/docs/integrations/zerobounce.mdx index d39a881782b..42326188d92 100644 --- a/apps/docs/content/docs/integrations/zerobounce.mdx +++ b/apps/docs/content/docs/integrations/zerobounce.mdx @@ -6,12 +6,13 @@ integration: color: "#FFFFFF" --- +import { ReferenceDetails } from "@/components/ui/reference-details" + {/* MANUAL-CONTENT-START:intro */} Use ZeroBounce to validate one email address or check remaining validation credits. Inspect the returned status for invalid, catch-all, spamtrap, abuse, or do-not-mail addresses before using the contact in an outreach workflow. {/* MANUAL-CONTENT-END */} - ## Use the block [#usage-instructions] Integrate ZeroBounce to validate email deliverability in real time — detect invalid, catch-all, spamtrap, abuse, and do-not-mail addresses — and check your remaining validation credits. @@ -22,6 +23,8 @@ Integrate ZeroBounce to validate email deliverability in real time — detect in Validate an email address deliverability in real time. Uses one validation credit. + + #### Input | Parameter | Type | Required | Description | @@ -40,10 +43,14 @@ Validate an email address deliverability in real time. Uses one validation credi | `freeEmail` | boolean | Whether the address is on a free email provider | | `didYouMean` | string | Suggested correction for a likely typo | + + ### ZeroBounce Get Credits Retrieve the remaining validation credits for the authenticated account. + + #### Input | Parameter | Type | Required | Description | @@ -56,4 +63,6 @@ Retrieve the remaining validation credits for the authenticated account. | --------- | ---- | ----------- | | `credits` | number | Remaining validation credits \(-1 if unavailable\) | + + diff --git a/apps/docs/content/docs/integrations/zoho_desk.mdx b/apps/docs/content/docs/integrations/zoho_desk.mdx index d4a7721bacd..260d52aaad7 100644 --- a/apps/docs/content/docs/integrations/zoho_desk.mdx +++ b/apps/docs/content/docs/integrations/zoho_desk.mdx @@ -1799,6 +1799,8 @@ A trigger starts a workflow when a supported event occurs in this service. Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, contact, agent, task, or article changes). + + #### Configuration | Parameter | Type | Required | Description | @@ -1821,3 +1823,5 @@ Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, conta | `payload` | json | The full resource that changed \(ticket, comment, thread, etc.\). Comment and thread events gain a derived plain-text `contentText` alongside the raw `content` + `contentType`; ticket events gain `descriptionText` alongside `description`. | | `prevState` | json | Previous state of the resource \(update events only\) | + + diff --git a/apps/docs/content/docs/integrations/zoom.mdx b/apps/docs/content/docs/integrations/zoom.mdx index 91f43ee280a..bdc7f802c83 100644 --- a/apps/docs/content/docs/integrations/zoom.mdx +++ b/apps/docs/content/docs/integrations/zoom.mdx @@ -550,6 +550,8 @@ Trigger workflow when a Zoom meeting starts Trigger workflow when a participant joins a Zoom meeting + + #### Configuration | Parameter | Type | Required | Description | @@ -579,13 +581,14 @@ Trigger workflow when a participant joins a Zoom meeting | ↳ `leave_time` | string | Time participant left \(ISO 8601, present on participant_left\) | | ↳ `duration` | number | Seconds the participant was in the meeting \(when provided\) | - ---- + ### Zoom Participant Left Trigger workflow when a participant leaves a Zoom meeting + + #### Configuration | Parameter | Type | Required | Description | @@ -615,13 +618,14 @@ Trigger workflow when a participant leaves a Zoom meeting | ↳ `leave_time` | string | Time participant left \(ISO 8601, present on participant_left\) | | ↳ `duration` | number | Seconds the participant was in the meeting \(when provided\) | - ---- + ### Zoom Recording Completed Trigger workflow when a Zoom cloud recording is completed + + #### Configuration | Parameter | Type | Required | Description | @@ -652,13 +656,14 @@ Trigger workflow when a Zoom cloud recording is completed | ↳ `share_url` | string | URL to share the recording | | ↳ `recording_files` | json | Array of recording file objects \(e.g. id, file_type, play_url, download_url\) per Zoom cloud recording payloads | - ---- + ### Zoom Webhook (All Events) Trigger workflow on any Zoom webhook event + + #### Configuration | Parameter | Type | Required | Description | @@ -673,3 +678,5 @@ Trigger workflow on any Zoom webhook event | `event_ts` | number | Unix timestamp in milliseconds when the event occurred | | `payload` | json | Complete webhook payload \(structure varies by event type\) | + + diff --git a/apps/docs/content/docs/integrations/zoominfo.mdx b/apps/docs/content/docs/integrations/zoominfo.mdx index e1028c1a025..aedf441c1ba 100644 --- a/apps/docs/content/docs/integrations/zoominfo.mdx +++ b/apps/docs/content/docs/integrations/zoominfo.mdx @@ -109,6 +109,8 @@ Search ZoomInfo for contacts (people) by name, job title, company, and other fil Enrich up to 25 companies in one request with detailed firmographics, industry, financials, and more. + + #### Input | Parameter | Type | Required | Description | @@ -124,10 +126,14 @@ Enrich up to 25 companies in one request with detailed firmographics, industry, | --------- | ---- | ----------- | | `results` | array | Enrichment results, one per input with match status and attributes | + + ### ZoomInfo Enrich Contacts Enrich up to 25 contacts in one request with verified emails, phone numbers, job details, and more. + + #### Input | Parameter | Type | Required | Description | @@ -144,10 +150,14 @@ Enrich up to 25 contacts in one request with verified emails, phone numbers, job | --------- | ---- | ----------- | | `results` | array | Enrichment results, one per input with match status and attributes | + + ### ZoomInfo Search Intent Search for companies showing intent signals on specific topics. + + #### Input | Parameter | Type | Required | Description | @@ -177,10 +187,14 @@ Search for companies showing intent signals on specific topics. | `currentPage` | number | Current page number | | `totalPages` | number | Total number of pages available | + + ### ZoomInfo Search News Search ZoomInfo news articles by category, URL, or date range. + + #### Input | Parameter | Type | Required | Description | @@ -203,4 +217,6 @@ Search ZoomInfo news articles by category, URL, or date range. | `currentPage` | number | Current page number | | `totalPages` | number | Total number of pages available | + + diff --git a/apps/docs/content/docs/introduction/index.mdx b/apps/docs/content/docs/introduction/index.mdx index b6772bda232..6935e392a95 100644 --- a/apps/docs/content/docs/introduction/index.mdx +++ b/apps/docs/content/docs/introduction/index.mdx @@ -55,9 +55,15 @@ For tools outside the catalog, connect an [external MCP server](/agents/mcp) or The core source is available on [GitHub](https://github.com/simstudioai/sim) under the Apache 2.0 license. Enterprise features use a separate license that requires an active Enterprise subscription for production use. See [self-hosting](/platform/self-hosting) for installation requirements and configuration. +## Security and compliance + +Sim is SOC 2 Type II compliant. Find reports and security details in the [trust center](https://trust.sim.ai/). + ## Related docs - [Getting started](/getting-started): Build and run a simple summarizer. - [Chat](/chat): Build and manage workspace resources conversationally. - [Workflows](/workflows): Blocks, connections, triggers, and execution. - [Quick reference](/quick-reference): Look up builder actions. +- [Pricing](https://www.sim.ai/pricing): Compare Sim Cloud plans. +- [Comparisons](https://www.sim.ai/comparisons): Sim vs Microsoft Copilot Studio, n8n, Zapier, Make, Dust, and other AI agent platforms. diff --git a/apps/docs/content/docs/platform/costs.mdx b/apps/docs/content/docs/platform/costs.mdx index ece4195e3a7..31a95b8d67f 100644 --- a/apps/docs/content/docs/platform/costs.mdx +++ b/apps/docs/content/docs/platform/costs.mdx @@ -129,6 +129,7 @@ The settings page groups keys into **Models**, **Search & web**, and **Enrichmen | Nebius | Model calls | | Ollama Cloud | Model calls | | Kie | Model calls | + | Thinking Machines | Model calls | | Fal.ai | Image and video generation | diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 36c106d58d2..34978bde2ff 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -10,19 +10,25 @@ After your admin adds a source in **Settings → Sources**, connect your own acc ## Connect and verify 1. Join the Sim organization through its invitation or SSO. Use the verified email required by the provider connection. -2. Open **Integrations**, select the app, and choose **Connect** in its header. -3. Complete authorization in the new tab. If the popup is blocked, allow it and select **Connect** again. +2. Open **Integrations**, select the app, and choose the app's button under **Connect your account**. +3. Complete authorization in the new tab. If the popup is blocked, allow it and select the button again. 4. Return to **Search** or **Search → Assistant** and search for content your account can open. Read a result and follow its source link. -Your organization currently supports one connected account per provider. Open the app to see that account's permissions and connection actions. +To connect another account of the same app, such as Google Drive for two different work accounts, open the app and choose **Add account**, then pick the other account when the provider asks. You can connect up to five accounts per app, and Search looks across all of them. Open an account to see its tool permissions when the app supports them; each account's menu has **Reconnect** and **Disconnect**. + +- Slack accounts must belong to your organization's connected workspace. +- Zoom, Lucid, and Databricks allow one account, because Sim cannot yet tell their accounts apart. +- If the provider signs you straight back into a connected account, sign in to the other account with the provider first, then choose **Add account** again. + +Reconnecting keeps the same account. To use a different one, add it as a new account or disconnect the old one first. If Sim asks you to reconnect before adding another account, it could not confirm which account an existing connection belongs to. Reconnect that one, then add the other. Your organization and the provider must allow each account to authorize Sim. The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention. @@ -33,7 +39,7 @@ You do not choose repositories, channels, labels, or an indexing method during p | Source setup | Personal connection | | --- | --- | | **Member accounts** | Connect your own provider account. | -| **GitHub App** | Connect GitHub once per Sim organization. Each selected repository must be readable by you and the installation. | +| **GitHub App** | Connect one or more GitHub accounts. Each selected repository must be readable by you and the installation. | | Google service account: Gmail, Calendar, Drive | Connect each corresponding Google service. The source verifies your identity and resource access through delegation. | | Confluence service account | Connect Confluence. Both your account and the service connection must read the selected content. | | Coda | Connect personal Coda MCP. Service mode also verifies the parent document with its REST token. | @@ -46,7 +52,7 @@ Creating Jira sprints requires the Jira sprint permission. If your account was c ## Tool permissions -Open the app in **Integrations** and select your account. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: +Open the app in **Integrations**. If you connected several accounts, select one. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: | Preference | Behavior | | --- | --- | diff --git a/apps/docs/content/docs/search/flow.mdx b/apps/docs/content/docs/search/flow.mdx new file mode 100644 index 00000000000..6cffb0cd989 --- /dev/null +++ b/apps/docs/content/docs/search/flow.mdx @@ -0,0 +1,35 @@ +--- +title: Flow Engineering +description: Search current engineering items using your own Flow account +--- + +Flow Engineering Search retrieves current engineering items when you search. It uses **Member accounts** and respects the permissions of each person's API key. It does not crawl or index the workspace. + +## Connect + +1. An admin adds **Flow Engineering** in **Settings → Sources**. +2. In Flow, open **Settings → API & MCP** and copy your personal API key, API host, and workspace identifier. +3. In Sim **Integrations → Flow Engineering**, enter those values. The provider account email must match your verified Sim email. + +The default API host is `backend.branch.flowengineering.com`; use the host shown by your Flow instance. Sim saves the destination with the credential and verifies the current user before each Search operation. Other members cannot search with your connection. Reconnecting preserves the provider identity and workspace; use a new connection for a different account or workspace. + +You can connect up to five accounts per provider. Reconnect an existing account to rotate its credentials. + +## Search and read + +Use a short phrase, optionally scoped to a known Flow project ID. Without a project, Sim searches at most six accessible projects. Each project returns up to 100 matches, subject to the requested result limit. Flow does not provide a continuation cursor for this search endpoint, so narrow the project or query when results are incomplete. + +Sim reads the matched items with the same credential and project scope. Results include current text and scalar field values, formulas, and available modification timestamps. Dates and sorting apply to retrieved candidates. Comments, attached files, and structured field values are not included; item text is limited to 120,000 characters with a truncation notice. + +Citations currently point to the exact REST item resource, which may require API authentication to open. Search uses the default project branch. + +## Requests and permissions + +Search uses `GET /project`, `GET /project/{projectId}/entities/search`, and `GET /project/{projectId}/entities/{entity_ids}`. Reads use the project-scoped entity endpoint; identity checks use `GET /users/me`. Requests carry the saved API key and workspace identifier. + +Live Search applies timeouts, response-size limits, and a per-call request budget. These are not a fixed requests-per-minute ceiling. Rate limits and permission denials are reported; inaccessible or deleted items are not returned. + +## Related docs + +- [Flow API authentication](https://docs.flowengineering.com/api/authentication) +- [Flow API documentation](https://docs.flowengineering.com/api) diff --git a/apps/docs/content/docs/search/jama.mdx b/apps/docs/content/docs/search/jama.mdx new file mode 100644 index 00000000000..c36f1c931a0 --- /dev/null +++ b/apps/docs/content/docs/search/jama.mdx @@ -0,0 +1,38 @@ +--- +title: Jama Connect +description: Search current Jama Connect items using your own account +--- + +Jama Connect Search retrieves items directly from your instance when you search. It uses **Member accounts**, with each person's own API credentials and current Jama permissions. It does not crawl projects or maintain a content index. + +## Connect + +1. An admin adds **Jama Connect** in **Settings → Sources**. +2. In **Integrations → Jama Connect**, connect your own account with your instance URL, client ID, and client secret from your Jama profile. +3. Use the same email address in Jama and your verified Sim account. Your Jama user must be active and permitted to use the REST API. + +Sim exchanges the saved client credentials for a short-lived access token. Searches and reads check the current Jama identity before retrieving content. Shared administrator credentials do not authorize other members' searches. A different instance or provider account requires a new connection. + +Self-hosted instance URLs may include a deployment context path. The instance must be reachable from Sim's server environment. If your organization uses dedicated outbound IPs, allowlist those addresses for HTTPS access. + +You can connect up to five accounts per provider. Reconnect an existing account to rotate its credentials. + +## Search and read + +Search with a short phrase or distinctive requirement text. A native query can specify a numeric project ID. Each call retrieves up to 50 items; continue with the returned cursor to request another page. + +Results include item keys, names, available text and scalar fields, and modification times. Reads retrieve the current item again. Date filters and newest/oldest sorting apply to the returned candidates, not the entire instance. + +Attachments, rich structured fields, and comment discussions are not included. Item text is bounded at 120,000 characters and reports truncation. Citations currently point to the exact REST item resource rather than a Jama application deep link; opening them may require API authentication. + +## Requests + +Search uses `GET /rest/v1/abstractitems`, reads use `GET /rest/v1/abstractitems/{id}`, and identity checks use `GET /rest/v1/users/current`. Token exchange uses `POST /rest/oauth/token`. + +Requests share Sim's live Search timeout, response-size limit, and per-call request budget. These safeguards are not a fixed requests-per-minute limit. Jama access and rate-limit errors are reported without widening permissions or falling back to a shared account. + +## Related docs + +- [Jama API credentials](https://help.jamasoftware.com/en/get-started/setting-up-your-work-environment/set-api-credentials.html) +- [Jama REST API](https://rest.jamasoftware.com/) +- [Abstract item search](https://dev.jamasoftware.com/cookbook/#abstract-items) diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index 7682fb5c4be..445c9e6a3c0 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -13,6 +13,7 @@ "coda", "confluence", "fireflies", + "flow", "github", "gitlab", "gmail", @@ -21,6 +22,7 @@ "google-meet", "granola", "hubspot", + "jama", "jira", "linear", "lucid", diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index 28a9f676ea0..b94b4fa818b 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -44,6 +44,12 @@ Select a model beginning with `nebius/` to run an open model through Nebius Toke Bring your own Nebius Token Factory API key. On Sim Cloud, a workspace admin can add it under **Workspace settings → BYOK → Nebius**. On self-hosted Sim, provide the key in the Agent block's **API Key** field, using a [secret reference](/platform/credentials). Model features such as image input and structured outputs depend on the selected model. +#### Thinking Machines + +Select a model beginning with `thinkingmachines/` to run Inkling, Inkling-Small, or Inkling 2 Small through Tinker serverless inference. To sample a checkpoint you trained on Tinker, enter `thinkingmachines/` followed by its `tinker://` path. Custom checkpoints have no catalog pricing. + +Bring your own Thinking Machines Tinker API key. On Sim Cloud, a workspace admin can add it under **Workspace settings → BYOK → Thinking Machines**. On self-hosted Sim, provide the key in the Agent block's **API Key** field, using a [secret reference](/platform/credentials). Tinker's serverless inference is in beta: file attachments are not sent, tools run with automatic tool choice only, and structured outputs are requested through the prompt. + #### Jev evaluation models Select `jev-latest` from TypeSafe in the Agent model selector. Hosted Sim supplies a key and bills model usage through the normal credit system; workspace or organization BYOK keys override the hosted key without model charges. Self-hosted users enter their TypeSafe key in the block. Use `jev-1.13.0` to pin a version or `jev-preview` to follow preview releases. These models use **State** and **Questions** in place of conversational messages. State accepts text or a reference to a JSON object or array. Questions is a JSON object keyed by the answer names you want: diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 9f882a7f233..17f01a09649 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -18884,6 +18884,12 @@ "minLength": 1, "maxLength": 2048 }, + "providerWorkspaceId": { + "description": "Provider workspace identifier, separate from the Sim workspace.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, "atlassianProduct": { "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", "type": "string", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 7768b388293..b9192e47728 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -18825,6 +18825,12 @@ "confluence.spacesById", "confluence.pages", "google.tasks.lists", + "gauge.organizations", + "gauge.categories", + "gauge.topics", + "gauge.prompts", + "gauge.tags", + "gauge.answers", "fivetran.groups", "fivetran.connections", "gmail.labels", @@ -18832,6 +18838,11 @@ "google.drive", "google.sheets", "harmonic.savedSearches", + "jama.projects", + "jama.itemTypes", + "flow.projects", + "flow.categories", + "flow.branches", "helpscout.mailboxes", "helpscout.assignees", "hubspot.lists", @@ -19011,6 +19022,12 @@ "confluence.spacesById", "confluence.pages", "google.tasks.lists", + "gauge.organizations", + "gauge.categories", + "gauge.topics", + "gauge.prompts", + "gauge.tags", + "gauge.answers", "fivetran.groups", "fivetran.connections", "gmail.labels", @@ -19018,6 +19035,11 @@ "google.drive", "google.sheets", "harmonic.savedSearches", + "jama.projects", + "jama.itemTypes", + "flow.projects", + "flow.categories", + "flow.branches", "helpscout.mailboxes", "helpscout.assignees", "hubspot.lists", diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts index b817e80c227..81b193b4060 100644 --- a/apps/docs/scripts/test-navigation-e2e.ts +++ b/apps/docs/scripts/test-navigation-e2e.ts @@ -1435,6 +1435,108 @@ try { const interactive = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) try { + await check('Docs reference: wrapped sidebar labels stay left aligned', async () => { + const page = await interactive.newPage() + try { + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 900 }) + await open(page, '/integrations/zoominfo') + if (width < 1024) await page.getByRole('button', { name: 'Menu', exact: true }).click() + const guide = page.getByRole('button', { name: /Shared credential guides/ }) + await guide.scrollIntoViewIfNeeded() + const lines = await guide.locator('span').evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + return [...range.getClientRects()].map((rect) => ({ + left: rect.left, + labelLeft: element.getBoundingClientRect().left, + })) + }) + expect(lines.length).toBeGreaterThan(width >= 1024 ? 1 : 0) + for (const line of lines) expect(Math.abs(line.left - line.labelLeft)).toBeLessThan(1) + await page.screenshot({ path: join(directory, `sidebar-wrapped-label-${width}.png`) }) + if (width < 1024) await page.keyboard.press('Escape') + } + } finally { + await page.close() + } + }) + for (const path of ['/integrations/zoom', '/integrations/zoominfo']) { + await check(`Docs reference: field tables start collapsed on ${path}`, async () => { + const page = await interactive.newPage() + try { + await open(page, path) + const tables = page.locator('article table').filter({ + has: page.getByRole('columnheader', { + name: 'Parameter', + exact: true, + includeHidden: true, + }), + }) + expect(await tables.count()).toBeGreaterThan(0) + for (const table of await tables.all()) await expect(table).toBeHidden() + const disclosures = page.locator('article details') + for (const disclosure of await disclosures.all()) { + const summary = disclosure.locator(':scope > summary') + await summary.click() + await expect(disclosure.getByRole('table').first()).toBeVisible() + await summary.focus() + await page.keyboard.press('Enter') + await expect(disclosure.getByRole('table').first()).toBeHidden() + } + await page.screenshot({ + path: join(directory, `${path.split('/').at(-1)}-collapsed.png`), + }) + } finally { + await page.close() + } + }) + } + await check('Docs reference: TOC hover keeps a single active underline', async () => { + const page = await interactive.newPage() + try { + for (const theme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: theme }) + await open(page, '/getting-started') + const toc = page + .getByRole('navigation', { name: 'On this page' }) + .filter({ visible: true }) + const link = toc.getByRole('link', { name: 'Create a workflow', exact: true }) + await link.click() + await expect(link).toHaveAttribute('aria-current', 'location') + await expect + .poll(() => link.evaluate((element) => getComputedStyle(element).textDecorationLine)) + .toBe('underline') + const next = toc.getByRole('link', { name: 'Next steps', exact: true }) + const color = await next.evaluate((element) => getComputedStyle(element).color) + await next.hover() + await next.evaluate((element) => + Promise.all(element.getAnimations().map((animation) => animation.finished)) + ) + await expect + .poll(() => next.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgba(0, 0, 0, 0)') + await expect(next).toHaveCSS('color', color) + await expect(next).toHaveCSS('text-decoration-line', 'none') + await expect + .poll(() => + toc + .getByRole('link') + .filter({ visible: true }) + .evaluateAll( + (links) => + links.filter((element) => + getComputedStyle(element).textDecorationLine.includes('underline') + ).length + ) + ) + .toBe(1) + await page.screenshot({ path: join(directory, `toc-hover-${theme}.png`) }) + } + } finally { + await page.close() + } + }) await check( 'Docs polish: nested TOC anchors track the selected section across breakpoints', async () => { diff --git a/apps/sim/app/(landing)/pricing/pricing.tsx b/apps/sim/app/(landing)/pricing/pricing.tsx index 4b34e107313..70fb5851928 100644 --- a/apps/sim/app/(landing)/pricing/pricing.tsx +++ b/apps/sim/app/(landing)/pricing/pricing.tsx @@ -1,4 +1,5 @@ import { INTEGRATION_COUNT_LABEL } from '@sim/deployment-config/integration-claims' +import { CREDITS_PER_DOLLAR } from '@/lib/billing/constants' import { PricingPlans } from '@/app/(landing)/pricing/components/pricing-plans' import { PricingStructuredData } from '@/app/(landing)/pricing/components/pricing-structured-data' @@ -54,6 +55,19 @@ export default function Pricing() { className='mx-auto flex w-full max-w-[1728px] flex-col gap-7 px-10 pt-8 max-md:px-7 max-lg:px-8 max-xl:px-9' > +

+ Sim is open source under the Apache 2.0 license, so you can self-host it for free with + Docker Compose or Kubernetes. Enterprise features need a license.{' '} + + Read the self-hosting guide + + . On Sim Cloud, one credit is worth ${1 / CREDITS_PER_DOLLAR}. +

) diff --git a/apps/sim/app/_shell/providers/get-query-client.ts b/apps/sim/app/_shell/providers/get-query-client.ts index 7fe869b9212..fc986903949 100644 --- a/apps/sim/app/_shell/providers/get-query-client.ts +++ b/apps/sim/app/_shell/providers/get-query-client.ts @@ -1,7 +1,7 @@ import { isServer, QueryClient } from '@tanstack/react-query' import { isDesktopApp } from '@/lib/desktop' -export function makeQueryClient() { +function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { diff --git a/apps/sim/app/_shell/providers/theme-provider.tsx b/apps/sim/app/_shell/providers/theme-provider.tsx index b114efebd12..1f105f7883e 100644 --- a/apps/sim/app/_shell/providers/theme-provider.tsx +++ b/apps/sim/app/_shell/providers/theme-provider.tsx @@ -28,6 +28,7 @@ const NON_LANDING_LIGHT_SEGMENTS = [ 'unsubscribe', 'cli', 'credential-groups', + 'marketplace', ] as const /** diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 318e7c7ac98..bffd86acf57 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -7,7 +7,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' import { AuthType, type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' @@ -84,8 +83,6 @@ async function integrationsWithheldFromSession( * Get credentials for a specific provider */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const { searchParams } = new URL(request.url) const rawQuery = { @@ -100,11 +97,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parseResult.success) { const refinementError = parseResult.error.issues.find((err) => err.code === 'custom') if (refinementError) { - logger.warn(`[${requestId}] Invalid query parameters: ${refinementError.message}`) + logger.warn(`Invalid query parameters: ${refinementError.message}`) return NextResponse.json({ error: refinementError.message }, { status: 400 }) } - logger.warn(`[${requestId}] Invalid query parameters`, { + logger.warn('Invalid query parameters', { errors: parseResult.error.issues, }) @@ -119,7 +116,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // Authenticate requester (supports session and internal JWT) const authResult = await checkSessionOrInternalAuth(request) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthenticated credentials request rejected`) + logger.warn('Unauthenticated credentials request rejected') return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } const requesterUserId = authResult.userId @@ -132,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { action: 'read', }) if (!workflowAuthorization.allowed) { - logger.warn(`[${requestId}] Forbidden credentials request for workflow`, { + logger.warn('Forbidden credentials request for workflow', { requesterUserId, workflowId, status: workflowAuthorization.status, @@ -378,7 +375,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ credentials: [] }, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error fetching OAuth credentials`, error) + logger.error('Error fetching OAuth credentials', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index e6f7015b1bf..99e7dcc986b 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -33,7 +33,7 @@ const logger = createLogger('OAuthTokenAPI') export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - logger.info(`[${requestId}] OAuth token API POST request received`) + logger.info('OAuth token API POST request received') try { const parsed = await parseRequest( @@ -42,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { {}, { validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid token request`, { errors: error.issues }) + logger.warn('Invalid token request', { errors: error.issues }) return NextResponse.json( { error: getValidationErrorMessage(error, 'Validation failed') }, { status: 400 } @@ -64,14 +64,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const callerUserId = parsed.data.query.userId if (credentialAccountUserId && providerId) { - logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, { + logger.info('Fetching token by credentialAccountUserId + providerId', { credentialAccountUserId, providerId, }) const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || auth.authType !== AuthType.SESSION || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, { + logger.warn('Unauthorized request for credentialAccountUserId path', { success: auth.success, authType: auth.authType, }) @@ -80,7 +80,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (auth.userId !== credentialAccountUserId) { logger.warn( - `[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}` + `User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}` ) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) } @@ -117,7 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ accessToken }, { status: 200 }) } catch (error) { const message = getErrorMessage(error, 'Failed to get OAuth token') - logger.warn(`[${requestId}] OAuth token error: ${message}`) + logger.warn(`OAuth token error: ${message}`) return NextResponse.json({ error: message }, { status: 403 }) } } @@ -148,7 +148,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(result.token, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error getting access token`, error) + logger.error('Error getting access token', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) @@ -166,7 +166,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { {}, { validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.issues }) + logger.warn('Invalid query parameters', { errors: error.issues }) return NextResponse.json( { error: getValidationErrorMessage(error, 'Validation failed') }, { status: 400 } @@ -203,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } if (!credential.accessToken) { - logger.warn(`[${requestId}] No access token available for credential`) + logger.warn('No access token available for credential') return NextResponse.json({ error: 'No access token available' }, { status: 400 }) } @@ -225,7 +225,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(result.token, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error fetching access token`, error) + logger.error('Error fetching access token', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index ce7af14c86a..76ca4f278b6 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -196,12 +196,12 @@ async function updateCostInner(req: NextRequest, span: Span): Promise { - logger.warn(`[${requestId}] Invalid request body`, { + logger.warn('Invalid request body', { errors: error.issues, }) span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody) @@ -324,7 +324,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise }) => { const { identifier } = await context.params - const requestId = generateRequestId() try { const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, { @@ -184,7 +183,7 @@ export const PUT = withRouteHandler( .limit(1) if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + logger.warn(`Chat not found for identifier: ${identifier}`) return createCodedErrorResponse('Chat not found', 404) } @@ -206,14 +205,14 @@ export const PUT = withRouteHandler( if (attempts >= MAX_OTP_ATTEMPTS) { await deleteOTP('chat', deployment.id, email) - logger.warn(`[${requestId}] OTP already locked out for ${email}`) + logger.warn(`OTP already locked out for ${email}`) return createCodedErrorResponse('Too many failed attempts. Please request a new code.', 429) } if (storedOTP !== otp) { const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue) if (result === 'locked') { - logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) + logger.warn(`OTP invalidated after max failed attempts for ${email}`) return createCodedErrorResponse( 'Too many failed attempts. Please request a new code.', 429 @@ -238,7 +237,7 @@ export const PUT = withRouteHandler( return response } catch (error) { - logger.error(`[${requestId}] Error verifying OTP:`, error) + logger.error('Error verifying OTP:', error) return createCodedErrorResponse('Failed to process request', 500) } } diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index f2f00ac62ee..ae99d4ade4d 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -152,14 +152,14 @@ export const POST = withRouteHandler( .limit(1) if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + logger.warn(`Chat not found for identifier: ${identifier}`) return createCodedErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { - logger.warn(`[${requestId}] Chat is not active: ${identifier}`) + logger.warn(`Chat is not active: ${identifier}`) return createCodedErrorResponse('This chat is currently unavailable', 403) } @@ -231,7 +231,7 @@ export const POST = withRouteHandler( }) if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) + logger.warn(`Preprocessing failed: ${preprocessResult.error?.message}`) return createCodedErrorResponse( preprocessResult.error?.message || 'Failed to process request', preprocessResult.error?.statusCode || 500 @@ -242,7 +242,7 @@ export const POST = withRouteHandler( const resolvedActorUserId = actorUserId! const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { - logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) + logger.error(`Workflow ${deployment.workflowId} has no workspaceId`) // preprocessExecution reserved a billing concurrency slot; release it on // this early exit since no LoggingSession will finalize to free it. await releaseExecutionSlot(executionId) @@ -289,10 +289,10 @@ export const POST = withRouteHandler( if (uploadedFiles.length > 0) { workflowInput.files = uploadedFiles - logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`) + logger.info(`Successfully processed ${uploadedFiles.length} files`) } } catch (fileError: any) { - logger.error(`[${requestId}] Failed to process chat files:`, fileError) + logger.error('Failed to process chat files:', fileError) await loggingSession.safeStart({ userId: resolvedActorUserId, @@ -402,14 +402,14 @@ export const POST = withRouteHandler( }) return streamResponse } catch (error: any) { - logger.error(`[${requestId}] Error processing chat request:`, error) + logger.error('Error processing chat request:', error) // Setup failed before the workflow stream took over slot release; // free the reserved billing slot (idempotent if already released). await releaseExecutionSlot(executionId) return createCodedErrorResponse(error.message || 'Failed to process request', 500) } } catch (error: any) { - logger.error(`[${requestId}] Error processing chat request:`, error) + logger.error('Error processing chat request:', error) return createCodedErrorResponse(error.message || 'Failed to process request', 500) } finally { ticket.release() @@ -443,28 +443,26 @@ export const GET = withRouteHandler( .limit(1) if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + logger.warn(`Chat not found for identifier: ${identifier}`) return createCodedErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { - logger.warn(`[${requestId}] Chat is not active: ${identifier}`) + logger.warn(`Chat is not active: ${identifier}`) return createCodedErrorResponse('This chat is currently unavailable', 403) } const authResult = await validateChatAuth(requestId, deployment, request) if (!authResult.authorized) { - logger.info( - `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` - ) + logger.info(`Authentication required for chat: ${identifier}, type: ${deployment.authType}`) return createCodedErrorResponse(authResult.error || 'Authentication required', 401) } return createSuccessResponse(toChatConfigResponse(deployment)) } catch (error: any) { - logger.error(`[${requestId}] Error fetching chat info:`, error) + logger.error('Error fetching chat info:', error) return createCodedErrorResponse(error.message || 'Failed to fetch chat information', 500) } } diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts index 82a525b75f6..725a2dd97b9 100644 --- a/apps/sim/app/api/chat/[identifier]/sso/route.ts +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { isEmailAllowed } from '@/lib/core/security/deployment' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCodedErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -39,8 +39,6 @@ function rateLimited(retryAfterMs: number | undefined, fallbackMs: number) { export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { - const requestId = generateRequestId() - const ip = getClientIp(request) if (ip) { const ipRateLimit = await rateLimiter.checkRateLimitDirect( @@ -49,7 +47,7 @@ export const POST = withRouteHandler( { failClosed: true } ) if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) + logger.warn(`SSO eligibility rate limit exceeded from ${ip}`) return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs) } } @@ -72,7 +70,7 @@ export const POST = withRouteHandler( .limit(1) if (!deployment || !deployment.isActive) { - logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`) + logger.warn(`SSO check on missing/inactive chat: ${identifier}`) return createCodedErrorResponse('Chat not found', 404) } @@ -86,7 +84,7 @@ export const POST = withRouteHandler( { failClosed: true } ) if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, { + logger.warn('SSO eligibility resource rate limit exceeded', { deploymentId: deployment.id, }) return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs) diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts index 69df8e97457..066ffaac11c 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -11,7 +11,7 @@ import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -50,12 +50,10 @@ const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again * tokens. */ export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - try { const ip = getClientIp(req) if (!ip) { - logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`) + logger.warn('Unable to resolve client IP for public rate limit') return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) } const storageKey = `public:contact:${ip}` @@ -65,7 +63,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) if (!allowed) { - logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) + logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429, headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, @@ -74,7 +72,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const parsed = await parseRequest(submitContactContract, req, {}) if (!parsed.success) { - logger.warn(`[${requestId}] Invalid contact request data`) + logger.warn('Invalid contact request data') return parsed.response } @@ -82,7 +80,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { parsed.data.body if (typeof website === 'string' && website.trim().length > 0) { - logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip }) + logger.warn('Honeypot triggered, discarding', { ip }) return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) } @@ -96,7 +94,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (verification.success) { captchaVerified = true } else if (!verification.transportError) { - logger.warn(`[${requestId}] Captcha verification failed`, { + logger.warn('Captcha verification failed', { ip, errorCodes: verification.errorCodes, }) @@ -105,10 +103,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { status: 400 } ) } else { - logger.warn( - `[${requestId}] Captcha transport error, falling back to no-captcha rate limit`, - { ip } - ) + logger.warn('Captcha transport error, falling back to no-captcha rate limit', { ip }) } } @@ -120,7 +115,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { failClosed: true } ) if (!nocaptchaAllowed) { - logger.warn(`[${requestId}] Rate limit rejected (no-captcha) for IP ${ip}`) + logger.warn(`Rate limit rejected (no-captcha) for IP ${ip}`) return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) } } @@ -128,7 +123,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const topicLabel = getContactTopicLabel(topic) - logger.info(`[${requestId}] Processing contact request`, { + logger.info('Processing contact request', { email: `${email.substring(0, 3)}***`, topic, }) @@ -157,11 +152,11 @@ ${message} }) if (!emailResult.success) { - logger.error(`[${requestId}] Error sending contact request email`, emailResult.message) + logger.error('Error sending contact request email', emailResult.message) return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }) } - logger.info(`[${requestId}] Contact request email sent successfully`) + logger.info('Contact request email sent successfully') try { const confirmationHtml = await renderHelpConfirmationEmail( @@ -178,17 +173,17 @@ ${message} emailType: 'transactional', }) } catch (err) { - logger.warn(`[${requestId}] Failed to send contact confirmation email`, err) + logger.warn('Failed to send contact confirmation email', err) } return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) } catch (error) { if (error instanceof Error && error.message.includes('not configured')) { - logger.error(`[${requestId}] Email service configuration error`, error) + logger.error('Email service configuration error', error) return NextResponse.json({ error: 'Email service configuration error.' }, { status: 500 }) } - logger.error(`[${requestId}] Error processing contact request`, error) + logger.error('Error processing contact request', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 286b9e2da3f..02d96580eeb 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -502,7 +502,8 @@ export const POST = withRouteHandler((req: NextRequest) => { } if (reconciledOutcome !== 'updated') { - logger.error(`[${tracker.requestId}] Failed to update tool call status`, { + logger.error('Failed to update tool call status', { + requestId: tracker.requestId, userId: authenticatedUserId, toolCallId, status: effectiveStatus, @@ -523,7 +524,8 @@ export const POST = withRouteHandler((req: NextRequest) => { } catch (error) { const duration = tracker.getDuration() - logger.error(`[${tracker.requestId}] Unexpected error:`, { + logger.error('Unexpected error:', { + requestId: tracker.requestId, duration, error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index e973145c4f0..bc42f69c40a 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -40,7 +40,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { invalidJson: 'throw', validationErrorResponse: (error) => { - logger.error(`[${tracker.requestId}] Validation error:`, { + logger.error('Validation error:', { + requestId: tracker.requestId, duration: tracker.getDuration(), errors: error.issues, }) @@ -53,7 +54,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } = parsed.data.body - logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, { + logger.info('Processing copilot feedback submission', { + requestId: tracker.requestId, userId: authenticatedUserId, chatId, isPositiveFeedback, @@ -78,7 +80,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) .returning() - logger.info(`[${tracker.requestId}] Successfully saved copilot feedback`, { + logger.info('Successfully saved copilot feedback', { + requestId: tracker.requestId, feedbackId: feedbackRecord.feedbackId, userId: authenticatedUserId, isPositive: isPositiveFeedback, @@ -103,7 +106,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } catch (error) { const duration = tracker.getDuration() - logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, { + logger.error('Error submitting copilot feedback:', { + requestId: tracker.requestId, duration, error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, @@ -145,7 +149,9 @@ export const GET = withRouteHandler(async () => { .from(copilotFeedback) .where(eq(copilotFeedback.userId, authenticatedUserId)) - logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`) + logger.info(`Retrieved ${feedbackRecords.length} feedback records`, { + requestId: tracker.requestId, + }) return NextResponse.json({ success: true, @@ -156,7 +162,7 @@ export const GET = withRouteHandler(async () => { }, }) } catch (error) { - logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error) + logger.error('Error retrieving copilot feedback:', error, { requestId: tracker.requestId }) return createInternalServerErrorResponse('Failed to retrieve feedback') } }) diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 86691d296cd..a9121dbc06a 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -202,7 +202,8 @@ export const POST = withRouteHandler((req: NextRequest) => { return NextResponse.json({ success: true, results }) } catch (error) { - logger.error(`[${tracker.requestId}] Unexpected error:`, { + logger.error('Unexpected error:', { + requestId: tracker.requestId, duration: tracker.getDuration(), error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index f88f8e79a20..ff5eacf7a5a 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -139,6 +139,7 @@ export async function handleCredentialGroupOAuthCallback({ : error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' : 'failed' + if (error instanceof CredentialGroupOAuthError && error.failure) status = error.failure if (identityFailure) { switch (identityFailure.reason) { case 'email_unverified': diff --git a/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts b/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts new file mode 100644 index 00000000000..b25b37572af --- /dev/null +++ b/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { runDetached } from '@/lib/core/utils/background' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('AwsMarketplaceReconcileCron') + +const LOCK_KEY = 'aws-marketplace-reconcile-lock' +/** Outlasts the reconcile's 6-minute run budget; the schedule runs every 15 minutes. */ +const LOCK_TTL_SECONDS = 10 * 60 + +export const dynamic = 'force-dynamic' + +/** + * Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event + * touched within a day. Acknowledges the cron call at once and works in the background; a Redis + * lock keeps runs from overlapping, and every write is idempotent, so a replayed run is harmless. + * A no-op on deployments without `AWS_MARKETPLACE_PRODUCT_CODE`. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'AWS Marketplace reconcile') + if (authError) { + return authError + } + + if (!getAwsMarketplaceProductCode()) { + return NextResponse.json({ + success: true, + message: 'AWS Marketplace is not configured – skipped', + status: 'skip', + }) + } + + const lockValue = generateShortId() + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, { + reclaimOnFailure: true, + }) + if (!locked) { + return NextResponse.json( + { + success: true, + message: 'AWS Marketplace reconcile already in progress – skipped', + status: 'skip', + }, + { status: 202 } + ) + } + + runDetached('aws-marketplace-reconcile', async () => { + try { + const summary = await reconcileAwsMarketplaceLicenses() + logger.info('AWS Marketplace reconcile completed', { ...summary }) + } finally { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + } + }) + + return NextResponse.json( + { success: true, message: 'AWS Marketplace reconcile started', status: 'started' }, + { status: 202 } + ) +}) diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index 2c5a1905364..dd101b3b3e9 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -9,7 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -25,12 +25,10 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { } export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - try { const ip = getClientIp(req) if (!ip) { - logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`) + logger.warn('Unable to resolve client IP for public rate limit') return NextResponse.json( { error: 'Too many requests. Please try again later.' }, { status: 429 } @@ -43,7 +41,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) if (!allowed) { - logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) + logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) return NextResponse.json( { error: 'Too many requests. Please try again later.' }, { @@ -55,7 +53,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const parsed = await parseRequest(submitDemoRequestContract, req, {}) if (!parsed.success) { - logger.warn(`[${requestId}] Invalid demo request data`) + logger.warn('Invalid demo request data') return parsed.response } @@ -63,7 +61,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { parsed.data.body const attribution = formatAttributionForNotification((name) => req.cookies.get(name)?.value) - logger.info(`[${requestId}] Processing demo request`, { + logger.info('Processing demo request', { email: `${companyEmail.substring(0, 3)}***`, companySize, }) @@ -89,18 +87,18 @@ ${attribution ? `\nAttribution:\n${attribution}\n` : ''}` }) if (!emailResult.success) { - logger.error(`[${requestId}] Error sending demo request email`, emailResult.message) + logger.error('Error sending demo request email', emailResult.message) return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 }) } - logger.info(`[${requestId}] Demo request email sent successfully`) + logger.info('Demo request email sent successfully') return NextResponse.json( { success: true, message: 'Thanks! Our team will reach out shortly.' }, { status: 201 } ) } catch (error) { - logger.error(`[${requestId}] Error processing demo request`, error) + logger.error('Error processing demo request', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index dbef90ae0e7..f5882faae0f 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -10,7 +10,6 @@ import { savePersonalEnvironmentContract } from '@/lib/api/contracts/environment import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { lockPersonalEnvMap } from '@/lib/credentials/env-locks' import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment' @@ -20,12 +19,10 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('EnvironmentAPI') export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized environment variables update attempt`) + logger.warn('Unauthorized environment variables update attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -35,7 +32,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { {}, { validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid environment variables data`, { errors: error.issues }) + logger.warn('Invalid environment variables data', { errors: error.issues }) return NextResponse.json( { error: 'Invalid request data', details: error.issues }, { status: 400 } @@ -110,18 +107,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { - logger.error(`[${requestId}] Error updating environment variables`, error) + logger.error('Error updating environment variables', error) return NextResponse.json({ error: 'Failed to update environment variables' }, { status: 500 }) } }) export const GET = withRouteHandler(async () => { - const requestId = generateRequestId() - try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) + logger.warn('Unauthorized environment variables access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -145,7 +140,7 @@ export const GET = withRouteHandler(async () => { const { decrypted } = await decryptSecret(encryptedValue) return [key, { key, value: decrypted }] as const } catch (error) { - logger.error(`[${requestId}] Error decrypting variable ${key}`, error) + logger.error(`Error decrypting variable ${key}`, error) return [key, { key, value: '' }] as const } }) @@ -157,7 +152,7 @@ export const GET = withRouteHandler(async () => { return NextResponse.json({ data: decryptedVariables }, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Environment fetch error`, error) + logger.error('Environment fetch error', error) return NextResponse.json({ error: toError(error).message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 41234edc614..f3cb17a9143 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -797,12 +797,12 @@ export async function assertToolFileAccess( routeLogger: ReturnType ): Promise { if (typeof key !== 'string' || key.length === 0) { - routeLogger.warn(`[${requestId}] File access check rejected: missing key`) + routeLogger.warn('File access check rejected: missing key', { requestId }) return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) } const hasAccess = await verifyFileAccess(key, userId) if (!hasAccess) { - routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key }) + routeLogger.warn('File access denied for user', { requestId, userId, key }) return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) } return null diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index a13ae1464cd..737ce8af5fb 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -58,7 +58,7 @@ export const POST = withRouteHandler( const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized folder duplication attempt for ${sourceFolderId}`) + logger.warn(`Unauthorized folder duplication attempt for ${sourceFolderId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -67,7 +67,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { name, workspaceId, parentId, newId: clientNewId } = parsed.data.body - logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) + logger.info(`Duplicating folder ${sourceFolderId} for user ${session.user.id}`) const sourceFolder = await db .select() @@ -212,7 +212,7 @@ export const POST = withRouteHandler( const elapsed = Date.now() - startTime logger.info( - `[${requestId}] Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`, + `Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`, { foldersCount: folderMapping.size, workflowsCount: workflowStats.total, @@ -252,7 +252,7 @@ export const POST = withRouteHandler( } if (error instanceof FolderDuplicationError) { - logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, { + logger.warn(`Folder duplication rejected: ${error.message}`, { sourceFolderId, userId: session.user.id, }) @@ -267,7 +267,7 @@ export const POST = withRouteHandler( */ const orchestrationError = asOrchestrationError(error) if (orchestrationError) { - logger.warn(`[${requestId}] Folder duplication rejected: ${orchestrationError.message}`, { + logger.warn(`Folder duplication rejected: ${orchestrationError.message}`, { sourceFolderId, userId: session.user.id, }) @@ -278,10 +278,7 @@ export const POST = withRouteHandler( } const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`, - error - ) + logger.error(`Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`, error) return NextResponse.json({ error: 'Failed to duplicate folder' }, { status: 500 }) } } diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 88408b7a2c1..3d5525d230f 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -7,7 +7,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withTransactionRetry } from '@/lib/db/transaction' import { acquireFolderMutationLock } from '@/lib/folders/locks' @@ -17,11 +16,10 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') export const PUT = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized folder reorder attempt`) + logger.warn('Unauthorized folder reorder attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -32,9 +30,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (!permission || permission === 'read') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) + logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) return NextResponse.json({ error: 'Write access required' }, { status: 403 }) } @@ -171,7 +167,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } logger.info( - `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` + `Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` ) return NextResponse.json({ success: true, updated: validUpdates.length }) @@ -194,7 +190,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { ) } - logger.error(`[${requestId}] Error reordering folders`, error) + logger.error('Error reordering folders', error) return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts index 19084dc1e16..9634bf2732b 100644 --- a/apps/sim/app/api/help/integration-request/route.ts +++ b/apps/sim/app/api/help/integration-request/route.ts @@ -5,7 +5,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -22,12 +22,10 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { } export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - try { const ip = getClientIp(req) if (!ip) { - logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`) + logger.warn('Unable to resolve client IP for public rate limit') return NextResponse.json( { error: 'Too many requests. Please try again later.' }, { status: 429 } @@ -40,7 +38,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) if (!allowed) { - logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) + logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) return NextResponse.json( { error: 'Too many requests. Please try again later.' }, { @@ -62,7 +60,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { integrationName, email, useCase } = parsed.data.body - logger.info(`[${requestId}] Processing integration request`, { + logger.info('Processing integration request', { integrationName, email: `${email.substring(0, 3)}***`, }) @@ -84,11 +82,11 @@ ${useCase ? `Use Case:\n${useCase}` : 'No use case provided.'} }) if (!emailResult.success) { - logger.error(`[${requestId}] Error sending integration request email`, emailResult.message) + logger.error('Error sending integration request email', emailResult.message) return NextResponse.json({ error: 'Failed to send request' }, { status: 500 }) } - logger.info(`[${requestId}] Integration request email sent successfully`) + logger.info('Integration request email sent successfully') return NextResponse.json( { success: true, message: 'Integration request submitted successfully' }, @@ -96,14 +94,14 @@ ${useCase ? `Use Case:\n${useCase}` : 'No use case provided.'} ) } catch (error) { if (error instanceof Error && error.message.includes('not configured')) { - logger.error(`[${requestId}] Email service configuration error`, error) + logger.error('Email service configuration error', error) return NextResponse.json( { error: 'Email service is temporarily unavailable. Please try again later.' }, { status: 500 } ) } - logger.error(`[${requestId}] Error processing integration request`, error) + logger.error('Error processing integration request', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 3bbb7fef636..e190ac5dfd4 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -4,7 +4,6 @@ import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/co import { helpFormBodySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { isMultipartFieldValidationError, isPayloadSizeLimitError, @@ -27,12 +26,10 @@ const logger = createLogger('HelpAPI') const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - try { const session = await getSession() if (!session?.user?.email) { - logger.warn(`[${requestId}] Unauthorized help request attempt`) + logger.warn('Unauthorized help request attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -50,7 +47,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const workspaceId = formData.get('workspaceId') as string const userAgent = formData.get('userAgent') as string | null - logger.info(`[${requestId}] Processing help request`, { + logger.info('Processing help request', { type, email: `${email.substring(0, 3)}***`, // Log partial email for privacy }) @@ -62,7 +59,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (!validationResult.success) { - logger.warn(`[${requestId}] Invalid help request data`, { + logger.warn('Invalid help request data', { issues: validationResult.error.issues, }) return validationErrorResponse(validationResult.error) @@ -117,11 +114,11 @@ ${message} }) if (!emailResult.success) { - logger.error(`[${requestId}] Error sending help request email`, emailResult.message) + logger.error('Error sending help request email', emailResult.message) return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) } - logger.info(`[${requestId}] Help request email sent successfully`) + logger.info('Help request email sent successfully') try { const confirmationHtml = await renderHelpConfirmationEmail( @@ -138,7 +135,7 @@ ${message} emailType: 'transactional', }) } catch (err) { - logger.warn(`[${requestId}] Failed to send confirmation email`, err) + logger.warn('Failed to send confirmation email', err) } return NextResponse.json( @@ -150,14 +147,14 @@ ${message} return NextResponse.json({ error: error.message }, { status: 400 }) } if (isPayloadSizeLimitError(error)) { - logger.warn(`[${requestId}] Help request form data too large`, { message: error.message }) + logger.warn('Help request form data too large', { message: error.message }) return NextResponse.json( { error: `Request body exceeds the maximum allowed size of ${MAX_HELP_FORM_BYTES} bytes` }, { status: 413 } ) } if (error instanceof Error && error.message.includes('not configured')) { - logger.error(`[${requestId}] Email service configuration error`, error) + logger.error('Email service configuration error', error) return NextResponse.json( { error: @@ -167,7 +164,7 @@ ${message} ) } - logger.error(`[${requestId}] Error processing help request`, error) + logger.error('Error processing help request', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 73015ddb79b..5a8aa084b5f 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -6,7 +6,6 @@ import { parseRequest } from '@/lib/api/server' import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCodedErrorResponse } from '@/app/api/workflows/utils' @@ -17,12 +16,11 @@ export const GET = withRouteHandler( const parsed = await parseRequest(getJobStatusContract, request, context) if (!parsed.success) return parsed.response const { jobId: taskId } = parsed.data.params - const requestId = generateRequestId() try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized task status request`) + logger.warn('Unauthorized task status request') return createCodedErrorResponse(authResult.error || 'Authentication required', 401) } @@ -47,7 +45,7 @@ export const GET = withRouteHandler( action: 'read', }) if (!accessCheck.allowed) { - logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`) + logger.warn(`Access denied to workflow ${metadataToCheck.workflowId}`) return createCodedErrorResponse('Access denied', 403) } @@ -59,10 +57,10 @@ export const GET = withRouteHandler( } } } else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) { - logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`) + logger.warn(`Access denied to user ${metadataToCheck.userId}`) return createCodedErrorResponse('Access denied', 403) } else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) { - logger.warn(`[${requestId}] Access denied to job ${taskId}`) + logger.warn(`Access denied to job ${taskId}`) return createCodedErrorResponse('Access denied', 403) } @@ -79,7 +77,7 @@ export const GET = withRouteHandler( return NextResponse.json(response) } catch (error: unknown) { const errorMessage = toError(error).message - logger.error(`[${requestId}] Error fetching task status:`, error) + logger.error('Error fetching task status:', error) if (errorMessage?.includes('not found')) { return createCodedErrorResponse('Task not found', 404) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts index d7af3b8abc0..562c8d1e639 100644 --- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts @@ -105,7 +105,7 @@ function logRowNotHeldByLiveRun(staleCutoff: Date): SQL { */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - logger.info(`[${requestId}] Connector member sync scheduler triggered`) + logger.info('Connector member sync scheduler triggered') const authError = verifyCronAuth(request, 'Connector member sync scheduler') if (authError) return authError @@ -157,28 +157,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ]) if (recoveredRunning.length > 0) { - logger.warn(`[${requestId}] Recovered ${recoveredRunning.length} stale member sync run(s)`, { + logger.warn(`Recovered ${recoveredRunning.length} stale member sync run(s)`, { ids: recoveredRunning.map((row) => row.id), }) } if (recoveredPending.length > 0) { logger.warn( - `[${requestId}] Recovered ${recoveredPending.length} connector(s) whose queued member sync never started`, + `Recovered ${recoveredPending.length} connector(s) whose queued member sync never started`, { ids: recoveredPending.map((row) => row.id) } ) } if (closedLogs.length > 0) { - logger.warn(`[${requestId}] Closed ${closedLogs.length} orphaned member sync log(s)`) + logger.warn(`Closed ${closedLogs.length} orphaned member sync log(s)`) } /** Observation hygiene never holds back dispatch; an unfinished sweep resumes next tick. */ try { const sweep = await sweepStaleMemberObservations(now) if (sweep.members > 0) { - logger.warn(`[${requestId}] Swept observations of ${sweep.members} stale member(s)`, sweep) + logger.warn(`Swept observations of ${sweep.members} stale member(s)`, sweep) } } catch (error) { - logger.error(`[${requestId}] Stale member observation sweep failed`, { + logger.error('Stale member observation sweep failed', { error: getErrorMessage(error), }) } @@ -207,7 +207,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .orderBy(asc(knowledgeConnector.nextMemberSyncAt)) .limit(MAX_DISPATCHES_PER_TICK) - logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for member sync`) + logger.info(`Found ${dueConnectors.length} connectors due for member sync`) if (dueConnectors.length === 0) { return NextResponse.json({ @@ -231,10 +231,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requireRunnable: true, }) } catch (error) { - logger.error( - `[${requestId}] Failed to dispatch member sync for connector ${connector.id}`, - error - ) + logger.error(`Failed to dispatch member sync for connector ${connector.id}`, error) } }) @@ -244,7 +241,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { count: dueConnectors.length, }) } catch (error) { - logger.error(`[${requestId}] Connector member sync scheduler error`, error) + logger.error('Connector member sync scheduler error', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index f0a2f8d4513..338adb3645a 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -160,7 +160,7 @@ function reclaimPayload(message: string) { */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - logger.info(`[${requestId}] Connector sync scheduler triggered`) + logger.info('Connector sync scheduler triggered') const authError = verifyCronAuth(request, 'Connector sync scheduler') if (authError) { @@ -283,21 +283,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ]) if (recoveredConnectors.length > 0) { - logger.warn( - `[${requestId}] Recovered ${recoveredConnectors.length} stale syncing connectors`, - { ids: recoveredConnectors.map((c) => c.id) } - ) + logger.warn(`Recovered ${recoveredConnectors.length} stale syncing connectors`, { + ids: recoveredConnectors.map((c) => c.id), + }) } if (recoveredPendingConnectors.length > 0) { logger.warn( - `[${requestId}] Recovered ${recoveredPendingConnectors.length} connectors whose queued sync was never started`, + `Recovered ${recoveredPendingConnectors.length} connectors whose queued sync was never started`, { ids: recoveredPendingConnectors.map((c) => c.id) } ) } if (closedSyncLogs.length > 0) { - logger.warn(`[${requestId}] Closed ${closedSyncLogs.length} orphaned connector sync log(s)`) + logger.warn(`Closed ${closedSyncLogs.length} orphaned connector sync log(s)`) } const dueConnectors = await db @@ -323,7 +322,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .orderBy(asc(knowledgeConnector.nextSyncAt)) .limit(MAX_DISPATCHES_PER_TICK) - logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for sync`) + logger.info(`Found ${dueConnectors.length} connectors due for sync`) if (dueConnectors.length === 0) { return NextResponse.json({ @@ -347,7 +346,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requireRunnable: true, }) } catch (error) { - logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error) + logger.error(`Failed to dispatch sync for connector ${connector.id}`, error) } }) @@ -357,7 +356,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { count: dueConnectors.length, }) } catch (error) { - logger.error(`[${requestId}] Connector sync scheduler error`, error) + logger.error('Connector sync scheduler error', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index d560d65b713..73e9ab54703 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -5,7 +5,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { type DashboardStatsResponse, statsQueryParamsSchema } from '@/lib/api/contracts/logs' import { isZodError } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' @@ -31,12 +30,10 @@ export const revalidate = 0 * check and consumes only the surface-neutral halves. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized logs stats access attempt`) + logger.warn('Unauthorized logs stats access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -105,7 +102,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(stats, { status: 200 }) } catch (validationError) { if (isZodError(validationError)) { - logger.warn(`[${requestId}] Invalid logs stats request parameters`, { + logger.warn('Invalid logs stats request parameters', { errors: validationError.issues, }) return NextResponse.json( @@ -119,7 +116,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { throw validationError } } catch (error: any) { - logger.error(`[${requestId}] logs stats fetch error`, error) + logger.error('logs stats fetch error', error) return NextResponse.json({ error: error.message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/marketplace/aws/claim/contact/route.ts b/apps/sim/app/api/marketplace/aws/claim/contact/route.ts new file mode 100644 index 00000000000..cdd89a3ab9e --- /dev/null +++ b/apps/sim/app/api/marketplace/aws/claim/contact/route.ts @@ -0,0 +1,25 @@ +import { saveAwsMarketplaceClaimContactContract } from '@/lib/api/contracts/aws-marketplace' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { saveAwsMarketplaceClaimContact } from '@/lib/billing/aws-marketplace/application/use-cases' +import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim' + +export const POST = defineInternalJsonRoute({ + contract: saveAwsMarketplaceClaimContactContract, + operation: saveAwsMarketplaceClaimContact.operation, + useCase: saveAwsMarketplaceClaimContact, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ body }, { request }) => ({ + licenseId: body.licenseId, + contactEmail: body.contactEmail, + companyName: body.companyName || null, + claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null, + }), +}) diff --git a/apps/sim/app/api/marketplace/aws/claim/link/route.ts b/apps/sim/app/api/marketplace/aws/claim/link/route.ts new file mode 100644 index 00000000000..d531c65bd55 --- /dev/null +++ b/apps/sim/app/api/marketplace/aws/claim/link/route.ts @@ -0,0 +1,32 @@ +import { linkAwsMarketplaceLicenseContract } from '@/lib/api/contracts/aws-marketplace' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { linkAwsMarketplaceLicense } from '@/lib/billing/aws-marketplace/application/use-cases' +import { + AWS_MARKETPLACE_CLAIM_COOKIE, + serializeClaimCookie, +} from '@/lib/billing/aws-marketplace/claim' + +export const POST = defineInternalJsonRoute({ + contract: linkAwsMarketplaceLicenseContract, + operation: linkAwsMarketplaceLicense.operation, + useCase: linkAwsMarketplaceLicense, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ body }, { request }) => ({ + organizationId: body.organizationId, + licenseId: body.licenseId, + contactEmail: body.contactEmail, + companyName: body.companyName || null, + claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null, + }), + present: () => ({ success: true as const }), + /** The claim is spent once the link commits; the cookie that carried it goes with it. */ + finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }), +}) diff --git a/apps/sim/app/api/marketplace/aws/claim/register-account/route.ts b/apps/sim/app/api/marketplace/aws/claim/register-account/route.ts new file mode 100644 index 00000000000..de32a3677b8 --- /dev/null +++ b/apps/sim/app/api/marketplace/aws/claim/register-account/route.ts @@ -0,0 +1,31 @@ +import { registerAwsMarketplaceLicenseToAccountContract } from '@/lib/api/contracts/aws-marketplace' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { registerAwsMarketplaceLicenseToAccount } from '@/lib/billing/aws-marketplace/application/use-cases' +import { + AWS_MARKETPLACE_CLAIM_COOKIE, + serializeClaimCookie, +} from '@/lib/billing/aws-marketplace/claim' + +export const POST = defineInternalJsonRoute({ + contract: registerAwsMarketplaceLicenseToAccountContract, + operation: registerAwsMarketplaceLicenseToAccount.operation, + useCase: registerAwsMarketplaceLicenseToAccount, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ body }, { request }) => ({ + licenseId: body.licenseId, + contactEmail: body.contactEmail, + companyName: body.companyName || null, + claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null, + }), + present: () => ({ success: true as const }), + /** The claim is spent once the registration commits; the cookie that carried it goes with it. */ + finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }), +}) diff --git a/apps/sim/app/api/marketplace/aws/claim/route.ts b/apps/sim/app/api/marketplace/aws/claim/route.ts new file mode 100644 index 00000000000..673559651c5 --- /dev/null +++ b/apps/sim/app/api/marketplace/aws/claim/route.ts @@ -0,0 +1,22 @@ +import { getAwsMarketplaceClaimContract } from '@/lib/api/contracts/aws-marketplace' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readAwsMarketplaceClaim } from '@/lib/billing/aws-marketplace/application/use-cases' +import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim' + +export const GET = defineInternalJsonRoute({ + contract: getAwsMarketplaceClaimContract, + operation: readAwsMarketplaceClaim.operation, + useCase: readAwsMarketplaceClaim, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-claim' }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: (_parsed, { request }) => ({ + claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null, + }), +}) diff --git a/apps/sim/app/api/marketplace/aws/licenses/route.ts b/apps/sim/app/api/marketplace/aws/licenses/route.ts new file mode 100644 index 00000000000..55a664e581b --- /dev/null +++ b/apps/sim/app/api/marketplace/aws/licenses/route.ts @@ -0,0 +1,20 @@ +import { listAccountAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listAccountAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: listAccountAwsMarketplaceLicensesContract, + operation: listAccountAwsMarketplaceLicenses.operation, + useCase: listAccountAwsMarketplaceLicenses, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: () => ({}), + present: (licenses) => ({ licenses }), +}) diff --git a/apps/sim/app/api/marketplace/aws/register/route.ts b/apps/sim/app/api/marketplace/aws/register/route.ts new file mode 100644 index 00000000000..0c4181072ae --- /dev/null +++ b/apps/sim/app/api/marketplace/aws/register/route.ts @@ -0,0 +1,97 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace' +import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { + AWS_MARKETPLACE_ONBOARDING_PATH, + type AwsMarketplaceOnboardingError, +} from '@/lib/billing/aws-marketplace/onboarding' +import { registerAwsMarketplaceBuyer } from '@/lib/billing/aws-marketplace/registration' +import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { readFormDataWithLimit } from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AwsMarketplaceRegisterAPI') + +/** The form carries one opaque token; anything larger is not from AWS. */ +const MAX_FORM_BYTES = 16 * 1024 + +const REGISTER_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 20, + refillRate: 10, + refillIntervalMs: 60_000, +} + +function redirectToOnboarding(error?: AwsMarketplaceOnboardingError): NextResponse { + const url = new URL(AWS_MARKETPLACE_ONBOARDING_PATH, getBaseUrl()) + if (error) url.searchParams.set('error', error) + return NextResponse.redirect(url, 303) +} + +async function readRegistrationToken(request: NextRequest): Promise { + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/x-www-form-urlencoded') return null + let form: FormData + try { + form = await readFormDataWithLimit(request, { + maxBytes: MAX_FORM_BYTES, + label: 'AWS Marketplace registration', + }) + } catch { + return null + } + const token = form.get('x-amzn-marketplace-token') + const parsed = awsMarketplaceRegistrationFormSchema.safeParse({ + 'x-amzn-marketplace-token': typeof token === 'string' ? token : undefined, + }) + return parsed.success ? parsed.data['x-amzn-marketplace-token'] : null +} + +/** + * The listing's fulfillment URL. AWS sends the buyer's browser here with a cross-site form POST, + * so the request carries no Sim session and is authenticated only by the registration token, + * which ResolveCustomer verifies. The response is always a 303 to the onboarding page, carrying + * the claim cookie unless the license lapsed. The token is never logged. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const productCode = getAwsMarketplaceProductCode() + if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + try { + const rateLimited = await enforceIpRateLimit( + 'aws-marketplace-register', + request, + REGISTER_RATE_LIMIT + ) + if (rateLimited) return redirectToOnboarding('unavailable') + + const token = await readRegistrationToken(request) + if (!token) { + logger.warn('AWS Marketplace registration arrived without a usable token') + return redirectToOnboarding('invalid') + } + + const outcome = await registerAwsMarketplaceBuyer(productCode, token) + if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind) + + const response = redirectToOnboarding() + response.headers.append('set-cookie', serializeClaimCookie(outcome.claimToken)) + response.headers.set('Cache-Control', 'no-store') + return response + } catch (error) { + logger.error('AWS Marketplace registration failed', { error: getErrorMessage(error) }) + return redirectToOnboarding('unavailable') + } +}) + +/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */ +export const GET = withRouteHandler(async () => { + if (!getAwsMarketplaceProductCode()) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return redirectToOnboarding() +}) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index e356f7a17ee..bd3ff0915ac 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -18,6 +18,7 @@ import { } from '@/lib/credential-groups/mcp-oauth-state' import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { assertSafeOauthServerUrl, @@ -162,7 +163,9 @@ async function completeManagedMcpCallback(params: { }) } catch (error) { logger.error('Managed MCP OAuth callback failed', error) - return failureRedirect('failed') + return failureRedirect( + error instanceof CredentialGroupOAuthError && error.failure ? error.failure : 'failed' + ) } } diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index f7ec2c4ff23..90138bc1755 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -147,7 +147,8 @@ async function syncToolSchemasToWorkflows( if (updatedWorkflowIds.size > 0) { logger.info( - `[${requestId}] Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}` + `Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`, + { requestId } ) } @@ -166,7 +167,7 @@ export const POST = withRouteHandler( const paramsValidation = mcpServerIdParamsSchema.safeParse(await params) if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error) const { id: serverId } = paramsValidation.data - logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) + logger.info(`Refreshing MCP server: ${serverId}`, { requestId }) const [server] = await db .select() @@ -200,14 +201,12 @@ export const POST = withRouteHandler( workspaceId, 'force' ) - logger.info( - `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}` - ) + logger.info(`Discovered ${discoveredTools.length} tools from server ${serverId}`, { + requestId, + }) } catch (error) { discoveryError = truncate(categorizeError(error).message, 200, '') - logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, { - error: discoveryError, - }) + logger.warn(`Failed to connect to server ${serverId}`, { requestId, error: discoveryError }) } if (discoveryError === null) { @@ -223,7 +222,8 @@ export const POST = withRouteHandler( // Discovery already persisted status and cached tools; a workflow-sync // failure is a secondary propagation and must not fail the refresh with // a 500. Surface it as zero workflows updated instead. - logger.warn(`[${requestId}] Tool schema sync failed after successful discovery`, { + logger.warn('Tool schema sync failed after successful discovery', { + requestId, error: getErrorMessage(error), }) } @@ -288,7 +288,7 @@ export const POST = withRouteHandler( updatedWorkflowIds: syncResult.updatedWorkflowIds, }) } catch (error) { - logger.error(`[${requestId}] Error refreshing MCP server:`, error) + logger.error('Error refreshing MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) } }) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index e13a844f911..5f6cc2bcb11 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -45,13 +45,11 @@ export const PATCH = withRouteHandler( const body = parsedBody.data - logger.info( - `[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, - { - userId, - updates: Object.keys(body).filter((k) => k !== 'workspaceId'), - } - ) + logger.info(`Updating MCP server: ${serverId} in workspace: ${workspaceId}`, { + requestId, + userId, + updates: Object.keys(body).filter((k) => k !== 'workspaceId'), + }) const result = await performUpdateMcpServer({ workspaceId, @@ -84,7 +82,7 @@ export const PATCH = withRouteHandler( } const updatedServer = result.server - logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) + logger.info(`Successfully updated MCP server: ${serverId}`, { requestId }) /** * The echoed row never carries header values: the caller just supplied @@ -97,7 +95,7 @@ export const PATCH = withRouteHandler( } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error updating MCP server:`, error) + logger.error('Error updating MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500) } } diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index dde2f6c082c..09e4d248340 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -34,7 +34,7 @@ export const GET = withRouteHandler( 'mcp_tools.use' )(async (_request: NextRequest, { workspaceId, requestId, permission }) => { try { - logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) + logger.info(`Listing MCP servers for workspace ${workspaceId}`, { requestId }) const rows = await db .select() @@ -50,12 +50,12 @@ export const GET = withRouteHandler( const includeHeaderValues = permissionSatisfies(permission, 'write') const servers = rows.map((row) => projectInternalMcpServer(row, { includeHeaderValues })) - logger.info( - `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` - ) + logger.info(`Listed ${servers.length} MCP servers for workspace ${workspaceId}`, { + requestId, + }) return createMcpSuccessResponse({ servers }) } catch (error) { - logger.error(`[${requestId}] Error listing MCP servers:`, error) + logger.error('Error listing MCP servers:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500) } }) @@ -79,7 +79,8 @@ export const POST = withRouteHandler( const body = parsedBody.data - logger.info(`[${requestId}] Registering MCP server:`, { + logger.info('Registering MCP server:', { + requestId, name: body.name, transport: body.transport, workspaceId, @@ -124,9 +125,9 @@ export const POST = withRouteHandler( ) } - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` - ) + logger.info(`Successfully registered MCP server: ${body.name} (ID: ${result.serverId})`, { + requestId, + }) return createMcpSuccessResponse( result.updated @@ -137,7 +138,7 @@ export const POST = withRouteHandler( } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error registering MCP server:`, error) + logger.error('Error registering MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) } }) @@ -171,7 +172,7 @@ export const DELETE = withRouteHandler( ) } - logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) + logger.info(`Deleting MCP server: ${serverId} from workspace: ${workspaceId}`, { requestId }) const result = await performDeleteMcpServer({ workspaceId, @@ -190,11 +191,11 @@ export const DELETE = withRouteHandler( ) } - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) + logger.info(`Successfully deleted MCP server: ${serverId}`, { requestId }) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { - logger.error(`[${requestId}] Error deleting MCP server:`, error) + logger.error('Error deleting MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) } }) diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 3f147f75998..8387c540414 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -98,7 +98,8 @@ export const POST = withRouteHandler( const body = parsedBody.data - logger.info(`[${requestId}] Testing MCP server connection:`, { + logger.info('Testing MCP server connection:', { + requestId, name: body.name, transport: body.transport, url: body.url ? `${body.url.substring(0, 50)}...` : undefined, // Partial URL for security @@ -158,7 +159,7 @@ export const POST = withRouteHandler( ) if (missingVars.length > 0) { - logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars }) + logger.warn('Some environment variables not found:', { requestId, missingVars }) } // Re-validate domain and SSRF after env var resolution @@ -226,7 +227,7 @@ export const POST = withRouteHandler( result.toolCount = tools.length result.success = true } catch { - logger.warn(`[${requestId}] Connection established but could not list tools`) + logger.warn('Connection established but could not list tools', { requestId }) result.success = false result.error = 'Connection established but could not list tools' result.warnings = result.warnings || [] @@ -243,7 +244,8 @@ export const POST = withRouteHandler( ) } - logger.info(`[${requestId}] MCP server test successful:`, { + logger.info('MCP server test successful:', { + requestId, name: body.name, negotiatedVersion: result.negotiatedVersion, toolCount: result.toolCount, @@ -252,13 +254,13 @@ export const POST = withRouteHandler( } catch (error) { result.success = false result.error = sanitizeConnectionError(error) - logger.warn(`[${requestId}] MCP server test failed`, { error: result.error }) + logger.warn('MCP server test failed', { requestId, error: result.error }) } finally { if (client) { try { await client.disconnect() } catch { - logger.debug(`[${requestId}] Test client disconnect error (expected)`) + logger.debug('Test client disconnect error (expected)', { requestId }) } } } @@ -267,7 +269,7 @@ export const POST = withRouteHandler( } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error testing MCP server connection:`, error) + logger.error('Error testing MCP server connection:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to test server connection', 500) } }) diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index dded0044831..fd8e7f87e75 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -38,16 +38,18 @@ export const dynamic = 'force-dynamic' */ function discoveryFailureResponse(requestId: string, error: unknown) { if (isMcpServerUnavailableError(error)) { - logger.warn(`[${requestId}] MCP server unavailable for tool discovery`, { + logger.warn('MCP server unavailable for tool discovery', { + requestId, error: getErrorMessage(error), }) return createMcpErrorResponse(error, 'Failed to discover MCP tools', 422) } const { message, status } = categorizeError(error) if (status >= 500) { - logger.error(`[${requestId}] Error discovering MCP tools:`, error) + logger.error('Error discovering MCP tools:', error, { requestId }) } else { - logger.warn(`[${requestId}] Failed to discover MCP tools`, { + logger.warn('Failed to discover MCP tools', { + requestId, error: getErrorMessage(error), status, }) @@ -101,7 +103,7 @@ export const GET = withRouteHandler( const serverId = query.serverId const forceRefresh = query.refresh === 'true' - logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh }) + logger.info('Discovering MCP tools', { requestId, serverId, workspaceId, forceRefresh }) const tools = serverId ? await mcpService.discoverServerTools( @@ -127,9 +129,9 @@ export const GET = withRouteHandler( byServer, } - logger.info( - `[${requestId}] Discovered ${tools.length} tools from ${Object.keys(byServer).length} servers` - ) + logger.info(`Discovered ${tools.length} tools from ${Object.keys(byServer).length} servers`, { + requestId, + }) return createMcpSuccessResponse(responseData) } catch (error) { if ( @@ -159,7 +161,7 @@ export const POST = withRouteHandler( const { serverIds } = parsedBody.data - logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`) + logger.info(`Refreshing tools for ${serverIds.length} servers`, { requestId }) const results = await settleWithConcurrency( serverIds, @@ -185,7 +187,7 @@ export const POST = withRouteHandler( } }) - logger.info(`[${requestId}] Refresh completed: ${successes.length}/${serverIds.length}`) + logger.info(`Refresh completed: ${successes.length}/${serverIds.length}`, { requestId }) return createMcpSuccessResponse({ refreshed: successes, failed: failures, @@ -204,7 +206,7 @@ export const POST = withRouteHandler( ) { return createMcpErrorResponse(error, 'OAuth re-authorization required', 401) } - logger.error(`[${requestId}] Error refreshing tool discovery:`, error) + logger.error('Error refreshing tool discovery:', error, { requestId }) const { message, status } = categorizeError(error) return createMcpErrorResponse(new Error(message), 'Failed to refresh tool discovery', status) } diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index a06bdfc1229..5cda364b600 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -19,7 +19,7 @@ export const GET = withRouteHandler( 'mcp_tools.use' )(async (_request: NextRequest, { workspaceId, requestId }) => { try { - logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) + logger.info(`Fetching stored MCP tools for workspace ${workspaceId}`, { requestId }) const workflows = await db .select({ id: workflow.id, name: workflow.name }) @@ -69,12 +69,13 @@ export const GET = withRouteHandler( } logger.info( - `[${requestId}] Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows` + `Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`, + { requestId } ) return createMcpSuccessResponse({ tools: storedTools }) } catch (error) { - logger.error(`[${requestId}] Error fetching stored MCP tools:`, error) + logger.error('Error fetching stored MCP tools:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to fetch stored MCP tools', 500) } }) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 367a2bb6345..1120c4855e0 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -43,7 +43,7 @@ export const GET = withRouteHandler( try { const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) + logger.info(`Getting workflow MCP server: ${serverId}`, { requestId }) const [server] = await db .select({ @@ -75,13 +75,13 @@ export const GET = withRouteHandler( .from(workflowMcpTool) .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info( - `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` - ) + logger.info(`Found workflow MCP server: ${server.name} with ${tools.length} tools`, { + requestId, + }) return createMcpSuccessResponse({ server, tools }) } catch (error) { - logger.error(`[${requestId}] Error getting workflow MCP server:`, error) + logger.error('Error getting workflow MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) } }) @@ -111,7 +111,7 @@ export const PATCH = withRouteHandler( const body = parsedBody.data - logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) + logger.info(`Updating workflow MCP server: ${serverId}`, { requestId }) const result = await performUpdateWorkflowMcpServer({ serverId, @@ -135,13 +135,13 @@ export const PATCH = withRouteHandler( const updatedServer = result.server - logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) + logger.info(`Successfully updated workflow MCP server: ${serverId}`, { requestId }) return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error updating workflow MCP server:`, error) + logger.error('Error updating workflow MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to update workflow MCP server', 500) } } @@ -164,7 +164,7 @@ export const DELETE = withRouteHandler( try { const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) + logger.info(`Deleting workflow MCP server: ${serverId}`, { requestId }) const result = await performDeleteWorkflowMcpServer({ serverId, @@ -180,11 +180,11 @@ export const DELETE = withRouteHandler( mcpOrchestrationStatus(result.errorCode) ) } - logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) + logger.info(`Successfully deleted workflow MCP server: ${serverId}`, { requestId }) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { - logger.error(`[${requestId}] Error deleting workflow MCP server:`, error) + logger.error('Error deleting workflow MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to delete workflow MCP server', 500) } } diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 41965115308..22b6aab3fbf 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -41,7 +41,7 @@ export const GET = withRouteHandler( try { const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) - logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) + logger.info(`Getting tool ${toolId} from server ${serverId}`, { requestId }) const [server] = await db .select({ id: workflowMcpServer.id }) @@ -77,7 +77,7 @@ export const GET = withRouteHandler( return createMcpSuccessResponse({ tool }) } catch (error) { - logger.error(`[${requestId}] Error getting tool:`, error) + logger.error('Error getting tool:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) } }) @@ -107,7 +107,7 @@ export const PATCH = withRouteHandler( const body = parsedBody.data - logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) + logger.info(`Updating tool ${toolId} in server ${serverId}`, { requestId }) const result = await performUpdateWorkflowMcpTool({ serverId, @@ -131,13 +131,13 @@ export const PATCH = withRouteHandler( const updatedTool = result.tool - logger.info(`[${requestId}] Successfully updated tool ${toolId}`) + logger.info(`Successfully updated tool ${toolId}`, { requestId }) return createMcpSuccessResponse({ tool: updatedTool }) } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error updating tool:`, error) + logger.error('Error updating tool:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to update tool', 500) } } @@ -160,7 +160,7 @@ export const DELETE = withRouteHandler( try { const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) - logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) + logger.info(`Deleting tool ${toolId} from server ${serverId}`, { requestId }) const result = await performDeleteWorkflowMcpTool({ serverId, @@ -177,11 +177,11 @@ export const DELETE = withRouteHandler( mcpOrchestrationStatus(result.errorCode) ) } - logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) + logger.info(`Successfully deleted tool ${toolId}`, { requestId }) return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) } catch (error) { - logger.error(`[${requestId}] Error deleting tool:`, error) + logger.error('Error deleting tool:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to delete tool', 500) } } diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 49613fc08f6..9991ddc7fb4 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -40,7 +40,7 @@ export const GET = withRouteHandler( try { const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) + logger.info(`Listing tools for workflow MCP server: ${serverId}`, { requestId }) const [server] = await db .select({ id: workflowMcpServer.id }) @@ -80,11 +80,11 @@ export const GET = withRouteHandler( ) .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) + logger.info(`Found ${tools.length} tools for server ${serverId}`, { requestId }) return createMcpSuccessResponse({ tools }) } catch (error) { - logger.error(`[${requestId}] Error listing tools:`, error) + logger.error('Error listing tools:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) } }) @@ -114,7 +114,8 @@ export const POST = withRouteHandler( const body = parsedBody.data - logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, { + logger.info(`Adding tool to workflow MCP server: ${serverId}`, { + requestId, workflowId: body.workflowId, }) @@ -141,14 +142,15 @@ export const POST = withRouteHandler( const tool = result.tool logger.info( - `[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}` + `Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}`, + { requestId } ) return createMcpSuccessResponse({ tool }, 201) } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error adding tool:`, error) + logger.error('Error adding tool:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to add tool', 500) } } diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index f7a8f90fb19..29779dd4bb1 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -31,7 +31,7 @@ export const GET = withRouteHandler( 'deploy.mcp' )(async (_request: NextRequest, { workspaceId, requestId }) => { try { - logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`) + logger.info(`Listing workflow MCP servers for workspace ${workspaceId}`, { requestId }) const servers = await db .select({ @@ -85,12 +85,12 @@ export const GET = withRouteHandler( toolNames: toolNamesByServer[server.id] || [], })) - logger.info( - `[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}` - ) + logger.info(`Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`, { + requestId, + }) return createMcpSuccessResponse({ servers: serversWithToolNames }) } catch (error) { - logger.error(`[${requestId}] Error listing workflow MCP servers:`, error) + logger.error('Error listing workflow MCP servers:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to list workflow MCP servers', 500) } }) @@ -118,7 +118,8 @@ export const POST = withRouteHandler( const body = parsedBody.data - logger.info(`[${requestId}] Creating workflow MCP server:`, { + logger.info('Creating workflow MCP server:', { + requestId, name: body.name, workspaceId, workflowIds: body.workflowIds, @@ -146,15 +147,15 @@ export const POST = withRouteHandler( const { server } = result const addedTools = result.addedTools || [] - logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` - ) + logger.info(`Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`, { + requestId, + }) return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error creating workflow MCP server:`, error) + logger.error('Error creating workflow MCP server:', error, { requestId }) return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) } } diff --git a/apps/sim/app/api/organizations/[id]/aws-marketplace-licenses/route.ts b/apps/sim/app/api/organizations/[id]/aws-marketplace-licenses/route.ts new file mode 100644 index 00000000000..8794a608396 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/aws-marketplace-licenses/route.ts @@ -0,0 +1,20 @@ +import { listOrganizationAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listOrganizationAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationAwsMarketplaceLicensesContract, + operation: listOrganizationAwsMarketplaceLicenses.operation, + useCase: listOrganizationAwsMarketplaceLicenses, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ params }) => ({ organizationId: params.id }), + present: (licenses) => ({ licenses }), +}) diff --git a/apps/sim/app/api/resume/poll/route.ts b/apps/sim/app/api/resume/poll/route.ts index d16de246b13..aa8cb8fd115 100644 --- a/apps/sim/app/api/resume/poll/route.ts +++ b/apps/sim/app/api/resume/poll/route.ts @@ -2,7 +2,6 @@ import { db } from '@sim/db' import { pausedExecutions } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { and, asc, inArray, isNotNull, lte, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -11,6 +10,7 @@ import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { logFailureOnce } from '@/lib/core/errors/failure-log' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { @@ -58,7 +58,7 @@ interface RowResult { } export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateShortId() + const requestId = generateRequestId() const authError = verifyCronAuth(request, 'Time-pause resume poll') if (authError) return authError diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index b810d183aa8..2aa958fe13d 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -35,12 +35,12 @@ async function fetchAndAuthorize( .limit(1) if (!schedule) { - logger.warn(`[${requestId}] Schedule not found: ${scheduleId}`) + logger.warn(`Schedule not found: ${scheduleId}`, { requestId }) return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }) } if (!schedule.workflowId) { - logger.warn(`[${requestId}] Schedule has no workflow: ${scheduleId}`) + logger.warn(`Schedule has no workflow: ${scheduleId}`, { requestId }) return NextResponse.json({ error: 'Schedule has no associated workflow' }, { status: 400 }) } @@ -51,12 +51,12 @@ async function fetchAndAuthorize( }) if (!authorization.workflow) { - logger.warn(`[${requestId}] Workflow not found for schedule: ${scheduleId}`) + logger.warn(`Workflow not found for schedule: ${scheduleId}`, { requestId }) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } if (!authorization.allowed) { - logger.warn(`[${requestId}] User not authorized to modify schedule: ${scheduleId}`) + logger.warn(`User not authorized to modify schedule: ${scheduleId}`, { requestId }) return NextResponse.json( { error: authorization.message || 'Not authorized to modify this schedule' }, { status: authorization.status } @@ -91,7 +91,7 @@ export const GET = withRouteHandler( return NextResponse.json({ schedule: authResult.schedule }) } catch (error) { - logger.error(`[${requestId}] Failed to get schedule`, { error }) + logger.error('Failed to get schedule', { error }) return NextResponse.json({ error: 'Failed to get schedule' }, { status: 500 }) } } @@ -104,7 +104,7 @@ export const PUT = withRouteHandler( try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule update attempt`) + logger.warn('Unauthorized schedule update attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -136,7 +136,7 @@ export const PUT = withRouteHandler( .set({ status: 'disabled', nextRunAt: null, lastQueuedAt: null, updatedAt: new Date() }) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) + logger.info(`Disabled schedule: ${scheduleId}`) recordAudit({ workspaceId, @@ -165,13 +165,13 @@ export const PUT = withRouteHandler( } if (!schedule.cronExpression) { - logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`) + logger.error(`Schedule has no cron expression: ${scheduleId}`) return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 }) } const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC') if (!cronResult.isValid || !cronResult.nextRun) { - logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`) + logger.error(`Invalid cron expression for schedule: ${scheduleId}`) return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 }) } @@ -183,7 +183,7 @@ export const PUT = withRouteHandler( .set({ status: 'active', failedCount: 0, infraRetryCount: 0, updatedAt: now, nextRunAt }) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) + logger.info(`Reactivated schedule: ${scheduleId}`) recordAudit({ workspaceId, @@ -210,7 +210,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error updating schedule`, error) + logger.error('Error updating schedule', error) return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) } } @@ -225,7 +225,7 @@ export const DELETE = withRouteHandler( const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule delete attempt`) + logger.warn('Unauthorized schedule delete attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -235,7 +235,7 @@ export const DELETE = withRouteHandler( await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) - logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`) + logger.info(`Deleted schedule: ${scheduleId}`) recordAudit({ workspaceId, @@ -257,7 +257,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ message: 'Schedule deleted successfully' }) } catch (error) { - logger.error(`[${requestId}] Error deleting schedule`, error) + logger.error('Error deleting schedule', error) return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 }) } } diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index d92f4f68b45..bff1dbad8ec 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -363,12 +363,13 @@ async function restoreScheduleClaim( ) .returning({ id: workflowSchedule.id }) .catch((error) => { - logger.error(`[${requestId}] ${context}`, error) + logger.error(context, error, { requestId }) throw error }) if (!restored) { - logger.warn(`[${requestId}] ${context}`, { + logger.warn(context, { + requestId, scheduleId, currentClaim: currentClaim.toISOString(), activeClaim: activeClaim.toISOString(), @@ -743,7 +744,8 @@ async function reconcileExistingScheduleJob(params: { } if (!scheduleWorkflowId) { - logger.warn(`[${requestId}] Cannot reconcile schedule job without a workflow`, { + logger.warn('Cannot reconcile schedule job without a workflow', { + requestId, scheduleId: schedule.id, jobId: job.id, }) @@ -759,7 +761,8 @@ async function reconcileExistingScheduleJob(params: { `Failed to restore schedule ${schedule.id} claim for recovery` ) if (!restored) { - logger.info(`[${requestId}] Skipped schedule reconciliation after claim changed`, { + logger.info('Skipped schedule reconciliation after claim changed', { + requestId, scheduleId: schedule.id, jobId: job.id, }) @@ -819,7 +822,8 @@ async function deferClaimedScheduleAfterQueueFailure( const retryDelayMs = calculateScheduleInfraRetryDelayMs(retryAttempt) const nextRetryAt = new Date(now.getTime() + retryDelayMs) - logger.warn(`[${requestId}] Deferring schedule after queue infrastructure failure`, { + logger.warn('Deferring schedule after queue infrastructure failure', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, retryAttempt, @@ -843,7 +847,7 @@ async function deferClaimedScheduleAfterQueueFailure( ) ) .catch((updateError) => { - logger.error(`[${requestId}] ${context}`, updateError) + logger.error(context, updateError, { requestId }) throw updateError }) } @@ -870,7 +874,8 @@ async function handleClaimedScheduleSetupFailure( return } - logger.error(`[${requestId}] Non-retryable schedule setup failure`, { + logger.error('Non-retryable schedule setup failure', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, error: toError(error).message, @@ -1152,7 +1157,8 @@ async function executeDatabaseScheduleJob( tryStartDatabaseScheduleJob(jobId) ) if (startResult === 'not_pending') { - logger.info(`[${requestId}] Database schedule execution job is no longer pending`, { + logger.info('Database schedule execution job is no longer pending', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, jobId, @@ -1161,7 +1167,8 @@ async function executeDatabaseScheduleJob( } if (startResult === 'capacity_full') { - logger.info(`[${requestId}] Deferred database schedule execution because capacity is full`, { + logger.info('Deferred database schedule execution because capacity is full', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, jobId, @@ -1182,7 +1189,8 @@ async function executeDatabaseScheduleJob( await jobQueue.completeJob(jobId, output ?? null) } catch (error) { const errorMessage = toError(error).message - logger.error(`[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, { + logger.error(`Schedule execution failed for workflow ${schedule.workflowId}`, { + requestId, scheduleId: schedule.id, jobId, error: errorMessage, @@ -1262,7 +1270,8 @@ async function resumePendingDatabaseScheduleJobs( const claimState = await getScheduleClaimState(recoveryMetadata, claimedAt) if (claimState === 'released') { - logger.info(`[${requestId}] Cancelling stale pending schedule execution job`, { + logger.info('Cancelling stale pending schedule execution job', { + requestId, scheduleId: recoveryMetadata.scheduleId, workflowId: recoveryMetadata.workflowId, jobId: job.id, @@ -1270,7 +1279,8 @@ async function resumePendingDatabaseScheduleJobs( return cancelReleasedPendingDatabaseScheduleCarrier(job.id) } if (claimState === 'claimed_by_other') { - logger.info(`[${requestId}] Leaving pending schedule execution job for active claimant`, { + logger.info('Leaving pending schedule execution job for active claimant', { + requestId, scheduleId: recoveryMetadata.scheduleId, workflowId: recoveryMetadata.workflowId, jobId: job.id, @@ -1281,7 +1291,8 @@ async function resumePendingDatabaseScheduleJobs( const payloadValidation = getSchedulePayloadValidation(job.payload) if (!payloadValidation.success) { const error = `Invalid pending schedule execution payload: ${payloadValidation.error}` - logger.warn(`[${requestId}] Rejecting invalid pending schedule execution payload`, { + logger.warn('Rejecting invalid pending schedule execution payload', { + requestId, scheduleId: recoveryMetadata.scheduleId, workflowId: recoveryMetadata.workflowId, jobId: job.id, @@ -1299,7 +1310,8 @@ async function resumePendingDatabaseScheduleJobs( return true } - logger.info(`[${requestId}] Resuming pending database schedule execution job`, { + logger.info('Resuming pending database schedule execution job', { + requestId, scheduleId: recoveryMetadata.scheduleId, workflowId: recoveryMetadata.workflowId, jobId: job.id, @@ -1326,7 +1338,8 @@ async function resumePendingDatabaseScheduleJobs( } if (result.status === 'rejected') { - logger.error(`[${requestId}] Failed to resume pending database schedule execution job`, { + logger.error('Failed to resume pending database schedule execution job', { + requestId, jobId: pendingJobs[index]?.id, error: toError(result.reason).message, }) @@ -1364,7 +1377,8 @@ async function processScheduleItem( (existingJob.status === JOB_STATUS.PENDING && existingJob.attempts > 0)) ) { await recoverStaleDatabaseScheduleJobs(new Date()) - logger.info(`[${requestId}] Reconciled claimed database schedule execution jobs`, { + logger.info('Reconciled claimed database schedule execution jobs', { + requestId, scheduleId: schedule.id, jobId: scheduleJobId, }) @@ -1380,7 +1394,8 @@ async function processScheduleItem( activeJobClaim && isStaleScheduleClaim(activeJobClaim, existingJob) ) { - logger.warn(`[${requestId}] Cancelling stale schedule execution job`, { + logger.warn('Cancelling stale schedule execution job', { + requestId, scheduleId: schedule.id, jobId: existingJob.id, claimedAt: activeJobClaim.toISOString(), @@ -1404,7 +1419,8 @@ async function processScheduleItem( const payloadValidation = getSchedulePayloadValidation(databaseJob.payload) if (!payloadValidation.success) { const error = `Invalid pending schedule execution payload: ${payloadValidation.error}` - logger.warn(`[${requestId}] Rejecting invalid pending schedule execution payload`, { + logger.warn('Rejecting invalid pending schedule execution payload', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, jobId: scheduleJobId, @@ -1423,7 +1439,8 @@ async function processScheduleItem( return } - logger.info(`[${requestId}] Resuming pending database schedule execution job`, { + logger.info('Resuming pending database schedule execution job', { + requestId, scheduleId: schedule.id, jobId: scheduleJobId, }) @@ -1455,7 +1472,8 @@ async function processScheduleItem( databaseJob.status !== JOB_STATUS.PENDING && databaseJob.status !== JOB_STATUS.PROCESSING ) { - logger.info(`[${requestId}] Database schedule execution job reached terminal state`, { + logger.info('Database schedule execution job reached terminal state', { + requestId, scheduleId: schedule.id, jobId: scheduleJobId, status: databaseJob.status, @@ -1471,7 +1489,8 @@ async function processScheduleItem( return } - logger.info(`[${requestId}] Schedule execution job already exists`, { + logger.info('Schedule execution job already exists', { + requestId, scheduleId: schedule.id, jobId: scheduleJobId, status: databaseJob?.status ?? existingJob.status, @@ -1494,7 +1513,8 @@ async function processScheduleItem( return } if (existingJob) { - logger.info(`[${requestId}] Reconciling schedule claim for finished job`, { + logger.info('Reconciling schedule claim for finished job', { + requestId, scheduleId: schedule.id, jobId: scheduleJobId, status: existingJob.status, @@ -1581,9 +1601,9 @@ async function processScheduleItem( const classifiedError = isAsyncJobEnqueueError(error) ? error : null const acceptance = classifiedError?.acceptance ?? 'unknown' logger.error( - `[${requestId}] Failed to enqueue schedule execution for workflow ${schedule.workflowId}`, + `Failed to enqueue schedule execution for workflow ${schedule.workflowId}`, error, - { acceptance, jobId: scheduleJobId } + { requestId, acceptance, jobId: scheduleJobId } ) /** * The queue may have accepted the job before the response was lost, and @@ -1603,12 +1623,13 @@ async function processScheduleItem( return } - logger.info( - `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` - ) + logger.info(`Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`, { + requestId, + }) if (useDatabaseFallback) { - logger.info(`[${requestId}] Executing durable database schedule execution job`, { + logger.info('Executing durable database schedule execution job', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, jobId, @@ -1629,7 +1650,8 @@ async function processScheduleItem( const queuedJob = await jobQueue.getJob(jobId) if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { - logger.info(`[${requestId}] Schedule execution job already finished`, { + logger.info('Schedule execution job already finished', { + requestId, scheduleId: schedule.id, jobId, status: queuedJob.status, @@ -1650,7 +1672,8 @@ async function processScheduleItem( ) if (queuedJobClaim) { if (isStaleScheduleClaim(queuedJobClaim, queuedJob)) { - logger.warn(`[${requestId}] Cancelling stale queued schedule execution job`, { + logger.warn('Cancelling stale queued schedule execution job', { + requestId, scheduleId: schedule.id, jobId, claimedAt: queuedJobClaim.toISOString(), @@ -1676,7 +1699,8 @@ async function processScheduleItem( } } - logger.info(`[${requestId}] Schedule execution task accepted`, { + logger.info('Schedule execution task accepted', { + requestId, scheduleId: schedule.id, workflowId: schedule.workflowId, jobId, @@ -1686,8 +1710,9 @@ async function processScheduleItem( }) } catch (error) { logger.error( - `[${requestId}] Failed after queueing schedule execution for workflow ${schedule.workflowId}`, - error + `Failed after queueing schedule execution for workflow ${schedule.workflowId}`, + error, + { requestId } ) if (!enqueuedJobId && !carrierObservedOrLookupUncertain) { await handleClaimedScheduleSetupFailure( @@ -1701,7 +1726,8 @@ async function processScheduleItem( return } - logger.warn(`[${requestId}] Preserved schedule occurrence after carrier uncertainty`, { + logger.warn('Preserved schedule occurrence after carrier uncertainty', { + requestId, scheduleId: schedule.id, jobId: enqueuedJobId ?? scheduleJobId, error: toError(error).message, @@ -1771,8 +1797,9 @@ export async function runScheduleTick(requestId: string): Promise { const requestId = generateRequestId() - logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) + logger.info(`Scheduled execution triggered at ${new Date().toISOString()}`) const authError = verifyCronAuth(request, 'Schedule execution') if (authError) { diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index f3dc25aa919..bae568dedec 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -32,7 +32,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule query attempt`) + logger.warn('Unauthorized schedule query attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -64,7 +64,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { ) } - logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`) + logger.info(`Getting schedule for workflow ${workflowId}`) const conditions = [eq(workflowSchedule.workflowId, workflowId)] if (blockId) { @@ -114,7 +114,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { { headers } ) } catch (error) { - logger.error(`[${requestId}] Error retrieving workflow schedule`, error) + logger.error('Error retrieving workflow schedule', error) return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 }) } }) @@ -125,7 +125,7 @@ async function handleWorkspaceSchedules(requestId: string, userId: string, works return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - logger.info(`[${requestId}] Getting all schedules for workspace ${workspaceId}`) + logger.info(`Getting all schedules for workspace ${workspaceId}`, { requestId }) const workflowRows = await db .select({ diff --git a/apps/sim/app/api/search/personal-credentials/route.ts b/apps/sim/app/api/search/personal-credentials/route.ts new file mode 100644 index 00000000000..c7a37cf3a84 --- /dev/null +++ b/apps/sim/app/api/search/personal-credentials/route.ts @@ -0,0 +1,38 @@ +import { + removeSearchPersonalCredentialContract, + saveSearchPersonalCredentialContract, +} from '@/lib/api/contracts/search-personal-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + internalCredentialErrorPolicy, + internalPersonalCredentialConnectionErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + removeSearchPersonalCredential, + saveSearchPersonalCredential, +} from '@/lib/credentials/application/search-personal-credentials' + +export const POST = defineInternalJsonRoute({ + contract: saveSearchPersonalCredentialContract, + auth: internalSessionAuth, + operation: saveSearchPersonalCredential.operation, + rateLimit: internalRateLimits.user({ bucketName: 'search-personal-credentials' }), + errorPolicy: internalPersonalCredentialConnectionErrorPolicy, + parseOptions: { maxBodyBytes: 16_384 }, + mapInput: ({ body }) => body, + useCase: saveSearchPersonalCredential, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: removeSearchPersonalCredentialContract, + auth: internalSessionAuth, + operation: removeSearchPersonalCredential.operation, + rateLimit: internalRateLimits.user({ bucketName: 'search-personal-credentials' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body }) => body, + useCase: removeSearchPersonalCredential, +}) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 456ff2b8d1d..a9ec91c2635 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -13,7 +13,6 @@ import { messageForOrchestrationError, statusForOrchestrationError, } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -71,12 +70,10 @@ const withReadOnly = (skills: T[]) => /** GET - Fetch all skills for a workspace */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const principal = await authenticatePrincipal() if (!principal) { - logger.warn(`[${requestId}] Unauthorized skills access attempt`) + logger.warn('Unauthorized skills access attempt') return unauthorized() } @@ -84,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { Object.fromEntries(request.nextUrl.searchParams.entries()) ) if (!query.success) { - logger.warn(`[${requestId}] Invalid skills query`, { errors: query.error.issues }) + logger.warn('Invalid skills query', { errors: query.error.issues }) return NextResponse.json( { error: 'Invalid request data', details: query.error.issues }, { status: 400 } @@ -101,19 +98,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } catch (error) { const projected = orchestrationErrorResponse(error, 'Failed to fetch skills') if (projected) return projected - logger.error(`[${requestId}] Error fetching skills:`, error) + logger.error('Error fetching skills:', error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) } }) /** POST - Create or update skills */ export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - try { const principal = await authenticatePrincipal() if (!principal) { - logger.warn(`[${requestId}] Unauthorized skills update attempt`) + logger.warn('Unauthorized skills update attempt') return unauthorized() } @@ -123,7 +118,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { {}, { validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid skills data`, { errors: error.issues }) + logger.warn('Invalid skills data', { errors: error.issues }) return validationErrorResponse(error, 'Invalid request data') }, } @@ -164,22 +159,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } catch (error) { const projected = orchestrationErrorResponse(error, 'Failed to update skills') if (projected) { - logger.warn(`[${requestId}] Skill write rejected`, { status: projected.status }) + logger.warn('Skill write rejected', { status: projected.status }) return projected } - logger.error(`[${requestId}] Error updating skills`, error) + logger.error('Error updating skills', error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) } }) /** DELETE - Delete a skill by ID */ export const DELETE = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const principal = await authenticatePrincipal() if (!principal) { - logger.warn(`[${requestId}] Unauthorized skill deletion attempt`) + logger.warn('Unauthorized skill deletion attempt') return unauthorized() } @@ -187,7 +180,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { Object.fromEntries(request.nextUrl.searchParams.entries()) ) if (!query.success) { - logger.warn(`[${requestId}] Invalid skill deletion query`, { errors: query.error.issues }) + logger.warn('Invalid skill deletion query', { errors: query.error.issues }) return NextResponse.json( { error: 'Invalid request data', details: query.error.issues }, { status: 400 } @@ -208,15 +201,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { { groups: { workspace: workspaceId } } ) - logger.info(`[${requestId}] Deleted skill: ${skillId}`) + logger.info(`Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) } catch (error) { const projected = orchestrationErrorResponse(error, 'Failed to delete skill') if (projected) { - logger.warn(`[${requestId}] Skill delete rejected`, { status: projected.status }) + logger.warn('Skill delete rejected', { status: projected.status }) return projected } - logger.error(`[${requestId}] Error deleting skill:`, error) + logger.error('Error deleting skill:', error) return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts index ecd2ef8e611..5aff52b2834 100644 --- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -56,7 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro excludeRowIds, }) logger.info( - `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${ + `cancel-runs: tableId=${tableId} scope=${scope}${ rowId ? ` rowId=${rowId}` : '' } cancelled=${cancelled}` ) @@ -74,7 +74,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (error instanceof TableQueryValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - logger.error(`[${requestId}] cancel-runs failed:`, error) + logger.error('cancel-runs failed:', error) return NextResponse.json({ error: 'Failed to cancel runs' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 57238d3acec..fb9a991d12c 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -37,7 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column creation attempt`) + logger.warn('Unauthorized column creation attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -84,7 +84,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return NextResponse.json({ error: msg }, { status: 404 }) } - logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) + logger.error(`Error adding column to table ${tableId}:`, error) return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) } }) @@ -97,7 +97,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column update attempt`) + logger.warn('Unauthorized column update attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -140,7 +140,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return validationErrorResponse(error, 'Invalid request data') } - logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) + logger.error(`Error updating column in table ${tableId}:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } }) @@ -154,7 +154,7 @@ export const DELETE = withRouteHandler( try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column deletion attempt`) + logger.warn('Unauthorized column deletion attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -202,7 +202,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: msg }, { status: 400 }) } - logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) + logger.error(`Error deleting column from table ${tableId}:`, error) return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) } } diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 4ac28aa8772..d5c4b38cabc 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -126,7 +126,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro throw error } - logger.info(`[${requestId}] Async row delete started`, { + logger.info('Async row delete started', { tableId, jobId, hasFilter: Boolean(filter), diff --git a/apps/sim/app/api/table/[tableId]/dispatches/route.ts b/apps/sim/app/api/table/[tableId]/dispatches/route.ts index 2043f2c4e4e..b086d19e1e9 100644 --- a/apps/sim/app/api/table/[tableId]/dispatches/route.ts +++ b/apps/sim/app/api/table/[tableId]/dispatches/route.ts @@ -63,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou }, }) } catch (error) { - logger.error(`[${requestId}] list-dispatches failed:`, error) + logger.error('list-dispatches failed:', error) return NextResponse.json({ error: 'Failed to list active dispatches' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 04e4915a632..6e5d10c9eef 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -105,7 +105,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (table.workspaceId !== workspaceId) { logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${workspaceId}, Actual: ${table.workspaceId}` + `Workspace ID mismatch for table ${tableId}. Provided: ${workspaceId}, Actual: ${table.workspaceId}` ) return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } @@ -195,7 +195,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) - logger.error(`[${requestId}] CSV import into existing table failed:`, error) + logger.error('CSV import into existing table failed:', error) return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index e455ec187e9..98feb44e0b2 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -23,7 +23,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized metadata update attempt`) + logger.warn('Unauthorized metadata update attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -61,7 +61,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ success: true, data: { metadata: updated } }) } catch (error) { - logger.error(`[${requestId}] Error updating table metadata:`, error) + logger.error('Error updating table metadata:', error) return NextResponse.json({ error: 'Failed to update metadata' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 1d2f3e726c6..7356259e071 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -47,14 +47,14 @@ export const POST = withRouteHandler( return orchestrationOutcomeErrorResponse(result, 'Failed to restore table') } - logger.info(`[${requestId}] Restored table ${tableId}`) + logger.info(`Restored table ${tableId}`) return NextResponse.json({ success: true, data: { table: result.table }, }) } catch (error) { - logger.error(`[${requestId}] Error restoring table ${tableId}`, error) + logger.error(`Error restoring table ${tableId}`, error) return NextResponse.json({ error: 'Failed to restore table' }, { status: 500 }) } } diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index e868b2e3f6d..48d74e04a7a 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -96,7 +96,7 @@ export const PATCH = withRouteHandler( try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized table rename attempt`) + logger.warn('Unauthorized table rename attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -214,7 +214,7 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 409 }) } - logger.error(`[${requestId}] Error updating table:`, error) + logger.error('Error updating table:', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to update table') }, { status: 500 } @@ -232,7 +232,7 @@ export const DELETE = withRouteHandler( try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized table delete attempt`) + logger.warn('Unauthorized table delete attempt') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } @@ -277,7 +277,7 @@ export const DELETE = withRouteHandler( return validationErrorResponse(error) } - logger.error(`[${requestId}] Error deleting table:`, error) + logger.error('Error deleting table:', error) return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } } diff --git a/apps/sim/app/api/table/[tableId]/rows/find/route.ts b/apps/sim/app/api/table/[tableId]/rows/find/route.ts index de7b8034a0b..5721b9667e4 100644 --- a/apps/sim/app/api/table/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/find/route.ts @@ -58,7 +58,7 @@ export const GET = withRouteHandler( if (validated.workspaceId !== table.workspaceId) { logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + `Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` ) return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } @@ -93,7 +93,7 @@ export const GET = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - logger.error(`[${requestId}] Error finding rows:`, error) + logger.error('Error finding rows:', error) return NextResponse.json({ error: 'Failed to find rows' }, { status: 500 }) } } diff --git a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts index 034d8059296..7ddf1f1a7ff 100644 --- a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts @@ -64,7 +64,7 @@ export const PATCH = withRouteHandler( if (error instanceof TableViewValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - logger.error(`[${requestId}] Error updating table view:`, error) + logger.error('Error updating table view:', error) return NextResponse.json({ error: 'Failed to update view' }, { status: 500 }) } } @@ -110,7 +110,7 @@ export const DELETE = withRouteHandler( if (error instanceof TableViewValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - logger.error(`[${requestId}] Error deleting table view:`, error) + logger.error('Error deleting table view:', error) return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 }) } } diff --git a/apps/sim/app/api/table/[tableId]/views/route.ts b/apps/sim/app/api/table/[tableId]/views/route.ts index b191b9bf55f..df62afde72e 100644 --- a/apps/sim/app/api/table/[tableId]/views/route.ts +++ b/apps/sim/app/api/table/[tableId]/views/route.ts @@ -45,7 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ success: true, data: { views } }) } catch (error) { - logger.error(`[${requestId}] Error listing table views:`, error) + logger.error('Error listing table views:', error) return NextResponse.json({ error: 'Failed to list views' }, { status: 500 }) } }) @@ -90,7 +90,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table if (error instanceof TableViewValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - logger.error(`[${requestId}] Error creating table view:`, error) + logger.error('Error creating table view:', error) return NextResponse.json({ error: 'Failed to create view' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index f3b03e66baa..056eebc7cbd 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -162,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) - logger.error(`[${requestId}] CSV import failed:`, error) + logger.error('CSV import failed:', error) return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts index f2225b308cd..ad7fd93dcdb 100644 --- a/apps/sim/app/api/table/jobs/route.ts +++ b/apps/sim/app/api/table/jobs/route.ts @@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { listTableJobsContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' @@ -21,8 +20,6 @@ export const dynamic = 'force-dynamic' * excluded from the table-level job derivation, so the tray reads them here. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) @@ -58,6 +55,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const jobs = await listWorkspaceExportJobs(workspaceId) - logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId }) + logger.info(`Listed ${jobs.length} export jobs`, { workspaceId }) return NextResponse.json({ success: true, data: { jobs } }) }) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 626cc311ace..ca8f1aadb92 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -347,14 +347,14 @@ export function accessError( context?: string ): NextResponse { if (result.capability) { - logger.warn( - `[${requestId}] ${capabilityRefusal(result.capability)}${context ? `: ${context}` : ''}` - ) + logger.warn(`${capabilityRefusal(result.capability)}${context ? `: ${context}` : ''}`, { + requestId, + }) return capabilityRefusalResponse(result.capability) } const message = result.status === 404 ? 'Table not found' : 'Access denied' - logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) + logger.warn(`${message}${context ? `: ${context}` : ''}`, { requestId }) return NextResponse.json({ error: message }, { status: result.status }) } diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index a15706753cd..baf4b307621 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -22,12 +22,10 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CustomToolsAPI') export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized custom tools access attempt`) + logger.warn('Unauthorized custom tools access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -47,7 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { action: 'read', }) if (!workflowAuthorization.allowed) { - logger.warn(`[${requestId}] Workflow authorization failed for custom tools`, { + logger.warn('Workflow authorization failed for custom tools', { workflowId, userId, status: workflowAuthorization.status, @@ -69,9 +67,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { resolvedWorkspaceId ) if (!userPermission) { - logger.warn( - `[${requestId}] User ${userId} does not have access to workspace ${resolvedWorkspaceId}` - ) + logger.warn(`User ${userId} does not have access to workspace ${resolvedWorkspaceId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } } @@ -92,7 +88,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ data: result }, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error fetching custom tools:`, error) + logger.error('Error fetching custom tools:', error) return NextResponse.json({ error: 'Failed to fetch custom tools' }, { status: 500 }) } }) @@ -103,7 +99,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized custom tools update attempt`) + logger.warn('Unauthorized custom tools update attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -114,7 +110,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { invalidJson: 'throw', validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid custom tools data`, { errors: error.issues }) + logger.warn('Invalid custom tools data', { errors: error.issues }) return NextResponse.json( { error: 'Invalid request data', @@ -131,20 +127,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { tools, workspaceId, source } = parsed.data.body if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId in request body`) + logger.warn('Missing workspaceId in request body') return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!userPermission) { - logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + logger.warn(`User ${userId} does not have access to workspace ${workspaceId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) + logger.warn(`User ${userId} does not have write permission for workspace ${workspaceId}`) return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } @@ -182,21 +176,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, data: resultTools }) } catch (error) { - logger.error(`[${requestId}] Error updating custom tools`, error) + logger.error('Error updating custom tools', error) const errorMessage = getErrorMessage(error, 'Failed to update custom tools') return NextResponse.json({ error: errorMessage }, { status: 500 }) } }) export const DELETE = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() const parsed = await parseRequest( deleteCustomToolContract, request, {}, { validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Missing tool ID for deletion`) + logger.warn('Missing tool ID for deletion') return NextResponse.json( { error: 'Tool ID is required', @@ -214,7 +207,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`) + logger.warn('Unauthorized custom tool deletion attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -227,7 +220,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { .limit(1) if (existingTool.length === 0) { - logger.warn(`[${requestId}] Tool not found: ${toolId}`) + logger.warn(`Tool not found: ${toolId}`) return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } @@ -235,33 +228,27 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { if (tool.workspaceId) { if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId for workspace-scoped tool`) + logger.warn('Missing workspaceId for workspace-scoped tool') return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!userPermission) { - logger.warn( - `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}` - ) + logger.warn(`User ${userId} does not have access to workspace ${workspaceId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) + logger.warn(`User ${userId} does not have write permission for workspace ${workspaceId}`) return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } if (tool.workspaceId !== workspaceId) { - logger.warn(`[${requestId}] Tool ${toolId} does not belong to workspace ${workspaceId}`) + logger.warn(`Tool ${toolId} does not belong to workspace ${workspaceId}`) return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } } else if (tool.userId !== userId) { - logger.warn( - `[${requestId}] User ${userId} attempted to delete tool they don't own: ${toolId}` - ) + logger.warn(`User ${userId} attempted to delete tool they don't own: ${toolId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } @@ -288,10 +275,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { metadata: { source }, }) - logger.info(`[${requestId}] Deleted tool: ${toolId}`) + logger.info(`Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) } catch (error) { - logger.error(`[${requestId}] Error deleting custom tool:`, error) + logger.error('Error deleting custom tool:', error) return NextResponse.json({ error: 'Failed to delete custom tool' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts index 654324e85d4..259fd5b3e82 100644 --- a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts +++ b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts @@ -7,7 +7,6 @@ import { } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { EmailType } from '@/lib/messaging/email/mailer' import { @@ -27,8 +26,6 @@ const UNSUBSCRIBE_RATE_LIMIT = { } export const GET = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT) if (rateLimited) return rateLimited @@ -43,14 +40,14 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } ) if (!parsed.success) { - logger.warn(`[${requestId}] Missing email or token in GET request`) + logger.warn('Missing email or token in GET request') return parsed.response } const { email, token } = parsed.data.query const tokenVerification = verifyUnsubscribeToken(email, token) if (!tokenVerification.valid) { - logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`) + logger.warn(`Invalid unsubscribe token for email: ${email}`) return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 }) } @@ -59,9 +56,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { const preferences = await getEmailPreferences(email) - logger.info( - `[${requestId}] Valid unsubscribe GET request for email: ${email}, type: ${emailType}` - ) + logger.info(`Valid unsubscribe GET request for email: ${email}, type: ${emailType}`) return NextResponse.json({ success: true, @@ -72,14 +67,12 @@ export const GET = withRouteHandler(async (req: NextRequest) => { currentPreferences: preferences || {}, }) } catch (error) { - logger.error(`[${requestId}] Error processing unsubscribe GET request:`, error) + logger.error('Error processing unsubscribe GET request:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT) if (rateLimited) return rateLimited @@ -101,14 +94,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } ) if (!parsed.success) { - logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`) + logger.warn('One-click unsubscribe missing email or token in URL') return parsed.response } email = parsed.data.query.email token = parsed.data.query.token - logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`) + logger.info(`Processing one-click unsubscribe for: ${email}`) } else { const parsed = await parseRequest( unsubscribePostContract, @@ -123,7 +116,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } ) if (!parsed.success) { - logger.warn(`[${requestId}] Invalid unsubscribe POST data`) + logger.warn('Invalid unsubscribe POST data') return parsed.response } @@ -134,7 +127,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const tokenVerification = verifyUnsubscribeToken(email, token) if (!tokenVerification.valid) { - logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`) + logger.warn(`Invalid unsubscribe token for email: ${email}`) return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 }) } @@ -142,7 +135,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const isTransactional = isTransactionalEmail(emailType) if (isTransactional) { - logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`) + logger.warn(`Attempted to unsubscribe from transactional email: ${email}`) return NextResponse.json( { error: 'Cannot unsubscribe from transactional emails', @@ -171,11 +164,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } if (!success) { - logger.error(`[${requestId}] Failed to update unsubscribe preferences for: ${email}`) + logger.error(`Failed to update unsubscribe preferences for: ${email}`) return NextResponse.json({ error: 'Failed to process unsubscribe request' }, { status: 500 }) } - logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`) + logger.info(`Successfully unsubscribed ${email} from ${type}`) return NextResponse.json( { @@ -188,7 +181,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { status: 200 } ) } catch (error) { - logger.error(`[${requestId}] Error processing unsubscribe POST request:`, error) + logger.error('Error processing unsubscribe POST request:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts b/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts new file mode 100644 index 00000000000..fb549dc5e7b --- /dev/null +++ b/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + adminDashboardLinkAwsMarketplaceLicenseContract, + adminDashboardListAwsMarketplaceLicensesContract, +} from '@/lib/api/contracts/v1/admin/dashboard' +import { parseRequest } from '@/lib/api/server' +import { + linkAwsMarketplaceLicenseAsAdmin, + listUnlinkedAwsMarketplaceLicenses, +} from '@/lib/billing/aws-marketplace/application/admin' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' +import { + adminConflictResponse, + adminInternalErrorResponse, + adminInvalidJsonResponse, + adminNotFoundResponse, + adminValidationErrorResponse, + listResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' + +const logger = createLogger('AdminAwsMarketplaceLicensesAPI') + +/** The Marketplace licenses no organization holds yet. */ +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace') + const parsed = await parseRequest( + adminDashboardListAwsMarketplaceLicensesContract, + request, + {}, + { validationErrorResponse: adminValidationErrorResponse } + ) + if (!parsed.success) return parsed.response + try { + const { limit, offset } = parsed.data.query + const { total, licenses } = await listUnlinkedAwsMarketplaceLicenses({ limit, offset }) + return listResponse(licenses, { + total, + limit, + offset, + hasMore: offset + licenses.length < total, + }) + } catch (error) { + logger.error('Failed to list AWS Marketplace licenses', { error }) + return adminInternalErrorResponse(getErrorMessage(error, 'Failed to list licenses')) + } + }) +) + +/** Links a Marketplace license to an organization once the team has provisioned it. */ +export const POST = withRouteHandler( + withAdminAuth(async (request) => { + if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace') + const parsed = await parseRequest( + adminDashboardLinkAwsMarketplaceLicenseContract, + request, + {}, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + } + ) + if (!parsed.success) return parsed.response + try { + const actor = await getAdminAuditActor(request) + return singleResponse(await linkAwsMarketplaceLicenseAsAdmin(parsed.data.body, actor)) + } catch (error) { + const refusal = asOrchestrationError(error) + if (refusal?.code === 'not_found') return adminNotFoundResponse(refusal.message) + if (refusal?.code === 'conflict') return adminConflictResponse(refusal.message) + logger.error('Failed to link an AWS Marketplace license', { error }) + return adminInternalErrorResponse(getErrorMessage(error, 'Failed to link the license')) + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index fb2cfa6b847..0624329b233 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -65,7 +65,7 @@ export const POST = withRouteHandler( const isDeployed = Boolean(result.activeDeployment) const attemptActivated = result.latestDeploymentAttempt?.status === 'active' logger.info( - `[${requestId}] Admin API: Deployment ${attemptActivated ? 'activated' : 'accepted'} for workflow ${workflowId}` + `Admin API: Deployment ${attemptActivated ? 'activated' : 'accepted'} for workflow ${workflowId}` ) const response: AdminV1DeployResult = { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index acc3c49884c..7591a85e05d 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -51,7 +51,7 @@ export const POST = withRouteHandler( } logger.info( - `[${requestId}] Admin API: ${result.latestDeploymentAttempt?.status === 'active' ? 'Activated' : 'Accepted activation for'} version ${versionNum} on workflow ${workflowId}` + `Admin API: ${result.latestDeploymentAttempt?.status === 'active' ? 'Activated' : 'Accepted activation for'} version ${versionNum} on workflow ${workflowId}` ) return singleResponse({ @@ -63,10 +63,7 @@ export const POST = withRouteHandler( latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error) { - logger.error( - `[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`, - { error } - ) + logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error }) return adminInternalErrorResponse('Failed to activate deployment version') } }) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 2cfc9b27606..9c575bfb83e 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -15,7 +15,6 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs' @@ -41,8 +40,6 @@ export const revalidate = 0 */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'audit-logs') if (!rateLimit.allowed) { @@ -89,7 +86,7 @@ export const GET = withRouteHandler( return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) { const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Audit log detail fetch error`, { error: message }) + logger.error('Audit log detail fetch error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 187e3a83b1e..b92911c4c05 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -21,7 +21,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' @@ -50,8 +49,6 @@ export const revalidate = 0 * is nothing here for a group to withhold. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'audit-logs') if (!rateLimit.allowed) { @@ -117,7 +114,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) { const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Audit logs fetch error`, { error: message }) + logger.error('Audit logs fetch error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 7b79984c1fc..bc1994d5d52 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files' import { parseRequest } from '@/lib/api/server' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' @@ -37,8 +36,6 @@ interface FileRouteParams { * capability twice. */ export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - const requestId = generateRequestId() - try { const rateLimit = await checkRateLimit(request, 'file-detail') if (!rateLimit.allowed) { @@ -91,15 +88,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo { status: statusForOrchestrationError(orchestrationError.code) } ) } - logger.error(`[${requestId}] Error downloading file:`, error) + logger.error('Error downloading file:', error) return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) } }) /** DELETE /api/v1/files/[fileId] — Archive a file. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - const requestId = generateRequestId() - try { const rateLimit = await checkRateLimit(request, 'file-detail') if (!rateLimit.allowed) { @@ -138,9 +133,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil return NextResponse.json({ error: result.error }, { status: 500 }) } - logger.info( - `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` - ) + logger.info(`Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`) return NextResponse.json({ success: true, @@ -149,7 +142,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil }, }) } catch (error) { - logger.error(`[${requestId}] Error deleting file:`, error) + logger.error('Error deleting file:', error) return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 249694ef5b3..462eb24ec05 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -4,7 +4,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/contracts/v1/files' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' import { isMultipartFieldValidationError, isPayloadSizeLimitError, @@ -37,8 +36,6 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024 /** GET /api/v1/files — List all files in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const rateLimit = await checkRateLimit(request, 'files') if (!rateLimit.allowed) { @@ -80,15 +77,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - logger.error(`[${requestId}] Error listing files:`, error) + logger.error('Error listing files:', error) return NextResponse.json({ error: 'Failed to list files' }, { status: 500 }) } }) /** POST /api/v1/files — Upload a file to a workspace. */ export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const rateLimit = await checkRateLimit(request, 'files') if (!rateLimit.allowed) { @@ -169,7 +164,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { secretProvenance: EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } ) - logger.info(`[${requestId}] Uploaded file: ${file.name} to workspace ${workspaceId}`) + logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) recordAudit({ workspaceId, @@ -219,7 +214,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: errorMessage }, { status: 409 }) } - logger.error(`[${requestId}] Error uploading file:`, error) + logger.error('Error uploading file:', error) return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts index 4cf6e70777e..abeb501818c 100644 --- a/apps/sim/app/api/v1/knowledge/utils.ts +++ b/apps/sim/app/api/v1/knowledge/utils.ts @@ -122,6 +122,6 @@ export function handleError( } } - logger.error(`[${requestId}] ${defaultMessage}:`, error) + logger.error(`${defaultMessage}:`, error, { requestId }) return NextResponse.json({ error: defaultMessage }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 1b384fc1639..3a33c4139e2 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' @@ -26,8 +25,6 @@ export const revalidate = 0 export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'logs-detail') if (!rateLimit.allowed) { @@ -109,7 +106,7 @@ export const GET = withRouteHandler( return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) } catch (error: any) { - logger.error(`[${requestId}] Log details fetch error`, { error: error.message }) + logger.error('Log details fetch error', { error: error.message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index d43b44d750f..b3e4b2e2227 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' @@ -30,8 +29,6 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'logs') if (!rateLimit.allowed) { @@ -91,7 +88,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return capabilityRefusalResponse(error.capability) } - logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { + logger.info(`Fetching logs for workspace ${params.workspaceId}`, { userId, filters: { workflowIds: params.workflowIds, @@ -209,7 +206,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response.body, { headers: response.headers }) } catch (error: any) { - logger.error(`[${requestId}] Logs fetch error`, { error: error.message }) + logger.error('Logs fetch error', { error: error.message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 2037c566048..2fef238d238 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -99,7 +99,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const classified = orchestrationErrorResponse(error) if (classified) return classified - logger.error(`[${requestId}] Error adding column to table:`, error) + logger.error('Error adding column to table:', error) return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) } }) @@ -160,7 +160,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - logger.error(`[${requestId}] Error updating column in table:`, error) + logger.error('Error updating column in table:', error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } }) @@ -230,7 +230,7 @@ export const DELETE = withRouteHandler( const classified = orchestrationErrorResponse(error) if (classified) return classified - logger.error(`[${requestId}] Error deleting column from table:`, error) + logger.error('Error deleting column from table:', error) return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index c9dcd9daae3..da191b2f71f 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -97,7 +97,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR }, }) } catch (error) { - logger.error(`[${requestId}] Error getting table:`, error) + logger.error('Error getting table:', error) return NextResponse.json({ error: 'Failed to get table' }, { status: 500 }) } }) @@ -172,7 +172,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - logger.error(`[${requestId}] Error deleting table:`, error) + logger.error('Error deleting table:', error) return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 40ca9f09ade..e2a7090c016 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -111,7 +111,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou }, }) } catch (error) { - logger.error(`[${requestId}] Error getting row:`, error) + logger.error('Error getting row:', error) return NextResponse.json({ error: 'Failed to get row' }, { status: 500 }) } }) @@ -204,7 +204,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const classified = orchestrationErrorResponse(error) if (classified) return classified - logger.error(`[${requestId}] Error updating row:`, error) + logger.error('Error updating row:', error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) } }) @@ -257,7 +257,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row if (lockError) return lockError const classified = orchestrationErrorResponse(error) if (classified) return classified - logger.error(`[${requestId}] Error deleting row:`, error) + logger.error('Error deleting row:', error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 3a497fdb39c..b626d602dc3 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -122,7 +122,7 @@ async function handleBatchInsert( const response = orchestrationErrorResponse(error) if (response) return response - logger.error(`[${requestId}] Error batch inserting rows:`, error) + logger.error('Error batch inserting rows:', error, { requestId }) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) } } @@ -217,7 +217,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ error: error.message }, { status: 400 }) } - logger.error(`[${requestId}] Error querying rows:`, error) + logger.error('Error querying rows:', error) return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) } }) @@ -318,7 +318,7 @@ export const POST = withRouteHandler( const response = orchestrationErrorResponse(error) if (response) return response - logger.error(`[${requestId}] Error inserting row:`, error) + logger.error('Error inserting row:', error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) } } @@ -413,7 +413,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const response = orchestrationErrorResponse(error) if (response) return response - logger.error(`[${requestId}] Error updating rows by filter:`, error) + logger.error('Error updating rows by filter:', error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) } }) @@ -507,7 +507,7 @@ export const DELETE = withRouteHandler( const response = orchestrationErrorResponse(error) if (response) return response - logger.error(`[${requestId}] Error deleting rows:`, error) + logger.error('Error deleting rows:', error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 503a072a35a..232f3e3f730 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -116,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const classified = orchestrationErrorResponse(error) if (classified) return classified - logger.error(`[${requestId}] Error upserting row:`, error) + logger.error('Error upserting row:', error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 5817317f4af..8502268ea97 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -29,8 +29,6 @@ export const revalidate = 0 /** GET /api/v1/tables — List all tables in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const rateLimit = await checkRateLimit(request, 'tables') if (!rateLimit.allowed) { @@ -83,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - logger.error(`[${requestId}] Error listing tables:`, error) + logger.error('Error listing tables:', error) return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) } }) @@ -186,7 +184,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const classified = orchestrationErrorResponse(error) if (classified) return classified - logger.error(`[${requestId}] Error creating table:`, error) + logger.error('Error creating table:', error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index a5c1a798391..61cf2671a44 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -58,7 +58,7 @@ export const POST = withRouteHandler( await assertWorkflowMutable(id) - logger.info(`[${requestId}] Deploying workflow ${id} via v1 API`, { userId }) + logger.info(`Deploying workflow ${id} via v1 API`, { userId }) const result = await performFullDeploy({ workflowId: id, @@ -100,7 +100,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflow deploy error`, { error: message }) + logger.error('Workflow deploy error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -135,7 +135,7 @@ export const DELETE = withRouteHandler( await assertWorkflowMutable(id) - logger.info(`[${requestId}] Undeploying workflow ${id} via v1 API`, { userId }) + logger.info(`Undeploying workflow ${id} via v1 API`, { userId }) const result = await performFullUndeploy({ workflowId: id, userId, requestId }) if (!result.success) { @@ -172,7 +172,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflow undeploy error`, { error: message }) + logger.error('Workflow undeploy error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index af48925e638..f68f5a2a4e3 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1ExportWorkflowContract } from '@/lib/api/contracts/v1/workflows' import { parseRequest } from '@/lib/api/server' @@ -33,8 +32,6 @@ export const revalidate = 0 */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'workflow-export') if (!rateLimit.allowed) { @@ -50,7 +47,7 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) + logger.info(`Exporting workflow ${id}`, { userId }) const workflowData = await getActiveWorkflowRecord(id) if (!workflowData?.workspaceId) { @@ -95,7 +92,7 @@ export const GET = withRouteHandler( return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) } catch (error: unknown) { const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflow export error`, { error: message }) + logger.error('Workflow export error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index e1a2317dafe..030af8c879d 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -75,10 +75,7 @@ export const POST = withRouteHandler( targetVersion = previous.version } - logger.info( - `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v1 API`, - { userId } - ) + logger.info(`Rolling back workflow ${id} to version ${targetVersion} via v1 API`, { userId }) const result = await performActivateVersion({ workflowId: id, @@ -117,7 +114,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflow rollback error`, { error: message }) + logger.error('Workflow rollback error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 1e01b403a3b..1bf194a5f58 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -3,7 +3,6 @@ import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetWorkflowContract } from '@/lib/api/contracts/v1/workflows' @@ -23,8 +22,6 @@ export const revalidate = 0 export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'workflow-detail') if (!rateLimit.allowed) { @@ -40,7 +37,7 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId }) + logger.info(`Fetching workflow details for ${id}`, { userId }) const workflowData = await getActiveWorkflowRecord(id) if (!workflowData) { @@ -94,7 +91,7 @@ export const GET = withRouteHandler( return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) } catch (error: unknown) { const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflow details fetch error`, { error: message }) + logger.error('Workflow details fetch error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index 4401acc57bc..dcfbbace6f7 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { type V1ImportWorkflowData, v1ImportWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { @@ -36,7 +36,7 @@ export const revalidate = 0 * route authenticates and renders the v1 envelope. */ export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) + const requestId = generateRequestId() try { const rateLimit = await checkRateLimit(request, 'workflow-import') @@ -59,7 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { workspaceId, folderId, name, description } = parsed.data.body - logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { + logger.info(`Importing workflow into workspace ${workspaceId}`, { userId, folderId, }) @@ -109,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers }) } catch (error: unknown) { const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflow import error`, { error: message }) + logger.error('Workflow import error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 7814a85ba8a..bc6a0cded4d 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -2,7 +2,6 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListWorkflowsContract } from '@/lib/api/contracts/v1/workflows' @@ -40,8 +39,6 @@ function decodeCursor(cursor: string): CursorData | null { } export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - try { const rateLimit = await checkRateLimit(request, 'workflows') if (!rateLimit.allowed) { @@ -61,7 +58,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query - logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, { + logger.info(`Fetching workflows for workspace ${params.workspaceId}`, { userId, filters: { folderId: params.folderId, @@ -166,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) { const message = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Workflows fetch error`, { error: message }) + logger.error('Workflows fetch error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index c34498d5636..5c950b4a2fa 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -200,7 +200,7 @@ export const POST = withRouteHandler( const messageId = generateId() const requestId = generateId() - let reqLogger = logger.withMetadata({ messageId, requestId }) + let reqLogger = logger.withMetadata({ messageId, simRequestId: requestId }) try { const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) @@ -284,7 +284,7 @@ export const POST = withRouteHandler( return v2Error('INTERNAL_ERROR', 'Internal server error') } const chatId = resolvedChat.chatId - reqLogger = logger.withMetadata({ chatId, messageId, requestId }) + reqLogger = logger.withMetadata({ chatId, messageId, simRequestId: requestId }) /** * Write this turn's display copy to the conversation the route just diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index 3b8a7d30dbe..24fbbcd3417 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -265,7 +265,8 @@ function streamPendingRun( send({ type: 'final', data: presentRun(result) }) }) .catch((error) => { - logger.error(`[${requestId}] v2 execute result stream failed`, { + logger.error('v2 execute result stream failed', { + requestId, error: getErrorMessage(error, 'Unknown error'), }) send({ type: 'error', error: 'Internal server error', status: 500 }) @@ -354,7 +355,7 @@ export const POST = withRouteHandler( const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER)) const callChainError = validateCallChain(incomingCallChain) if (callChainError) { - logger.warn(`[${requestId}] Call chain rejected`, { workflowId, error: callChainError }) + logger.warn('Call chain rejected', { workflowId, error: callChainError }) return v2Error('CONFLICT', callChainError, { details: { code: 'CALL_CHAIN_DEPTH_EXCEEDED' } }) } const callChain = buildNextCallChain(incomingCallChain, workflowId) @@ -654,7 +655,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) if (classified) return classified - logger.error(`[${requestId}] v2 execute failed`, { + logger.error('v2 execute failed', { workflowId, error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 6d68c132c1b..fbe1e60d148 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -146,17 +146,17 @@ async function updateUserStatsForWand( await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity) } catch (error) { - logger.error(`[${requestId}] Failed to update user stats for wand usage`, error) + logger.error('Failed to update user stats for wand usage', error, { requestId }) } } export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() - logger.info(`[${requestId}] Received wand generation request`) + logger.info('Received wand generation request') const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized wand generation attempt`) + logger.warn('Unauthorized wand generation attempt') return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } @@ -177,7 +177,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } = body if (!prompt) { - logger.warn(`[${requestId}] Invalid request: Missing prompt.`) + logger.warn('Invalid request: Missing prompt.') return NextResponse.json( { success: false, error: 'Missing required field: prompt.' }, { status: 400 } @@ -193,7 +193,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { .limit(1) if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + logger.warn(`Workflow not found: ${workflowId}`) return NextResponse.json({ success: false, error: 'Workflow not found' }, { status: 404 }) } @@ -206,14 +206,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) if (!permission || (permission !== 'admin' && permission !== 'write')) { logger.warn( - `[${requestId}] User ${session.user.id} does not have write access to workspace for workflow ${workflowId}` + `User ${session.user.id} does not have write access to workspace for workflow ${workflowId}` ) return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 403 }) } } else { - logger.warn( - `[${requestId}] Workflow ${workflowId} has no workspaceId; wand request blocked` - ) + logger.warn(`Workflow ${workflowId} has no workspaceId; wand request blocked`) return NextResponse.json( { success: false, @@ -258,12 +256,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (byokResult) { isBYOK = true activeOpenAIKey = byokResult.apiKey - logger.info(`[${requestId}] Using BYOK OpenAI key for wand generation`) + logger.info('Using BYOK OpenAI key for wand generation') } } if (!useWandAzure && !activeOpenAIKey) { - logger.error(`[${requestId}] AI client not initialized. Missing API key.`) + logger.error('AI client not initialized. Missing API key.') return NextResponse.json( { success: false, error: 'Wand generation service is not configured.' }, { status: 503 } @@ -346,9 +344,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (stream) { try { - logger.info( - `[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}` - ) + logger.info(`About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`) const apiUrl = useWandAzure ? `${azureEndpoint?.replace(/\/$/, '')}/openai/v1/responses?api-version=${azureApiVersion}` @@ -379,7 +375,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (!response.ok) { const errorText = await response.text() - logger.error(`[${requestId}] API request failed`, { + logger.error('API request failed', { status: response.status, statusText: response.statusText, error: errorText, @@ -387,7 +383,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { throw new Error(`API request failed: ${response.status} ${response.statusText}`) } - logger.info(`[${requestId}] Stream response received, starting processing`) + logger.info('Stream response received, starting processing') const encoder = new TextEncoder() const decoder = new TextDecoder() @@ -421,7 +417,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { done, value } = await reader.read() if (done) { - logger.info(`[${requestId}] Stream completed. Total chunks: ${chunkCount}`) + logger.info(`Stream completed. Total chunks: ${chunkCount}`) await flushUsage() controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)) controller.close() @@ -450,7 +446,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const data = trimmed.slice(5).trim() if (data === '[DONE]') { - logger.info(`[${requestId}] Received [DONE] signal`) + logger.info('Received [DONE] signal') await flushUsage() @@ -498,7 +494,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (content) { chunkCount++ if (chunkCount === 1) { - logger.info(`[${requestId}] Received first content chunk`) + logger.info('Received first content chunk') } controller.enqueue( @@ -515,15 +511,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { completion_tokens: usage.completionTokens, total_tokens: usage.totalTokens, } - logger.info( - `[${requestId}] Received usage data: ${JSON.stringify(finalUsage)}` - ) + logger.info(`Received usage data: ${JSON.stringify(finalUsage)}`) } } } } } catch (streamError: any) { - logger.error(`[${requestId}] Streaming error`, { + logger.error('Streaming error', { name: streamError?.name, message: streamError?.message || 'Unknown error', stack: streamError?.stack, @@ -532,7 +526,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { await flushUsage() } catch (usageError) { - logger.warn(`[${requestId}] Failed to record usage after stream error`, usageError) + logger.warn('Failed to record usage after stream error', usageError) } const errorData = `data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n` @@ -554,7 +548,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }, }) } catch (error: any) { - logger.error(`[${requestId}] Failed to create stream`, { + logger.error('Failed to create stream', { name: error?.name, message: error?.message || 'Unknown error', code: error?.code, @@ -612,16 +606,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const generatedContent = extractResponseText(completion.output)?.trim() if (!generatedContent) { - logger.error( - `[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.` - ) + logger.error(`${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.`) return NextResponse.json( { success: false, error: 'Failed to generate content. AI response was empty.' }, { status: 500 } ) } - logger.info(`[${requestId}] Wand generation successful`) + logger.info('Wand generation successful') const usage = parseResponsesUsage(completion.usage) if (usage) { @@ -639,7 +631,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, content: generatedContent }) } catch (error: any) { - logger.error(`[${requestId}] Wand generation failed`, { + logger.error('Wand generation failed', { name: error?.name, message: error?.message || 'Unknown error', code: error?.code, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 5b3b7571f06..41803bcc8f3 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -33,8 +33,6 @@ export const dynamic = 'force-dynamic' // Get a specific webhook export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(getWebhookContract, request, context) if (!parsed.success) return parsed.response @@ -42,7 +40,7 @@ export const GET = withRouteHandler( const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt`) + logger.warn('Unauthorized webhook access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -63,7 +61,7 @@ export const GET = withRouteHandler( .limit(1) if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${id}`) + logger.warn(`Webhook not found: ${id}`) return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) } @@ -77,14 +75,14 @@ export const GET = withRouteHandler( const hasAccess = authorization.allowed if (!hasAccess) { - logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`) + logger.warn(`User ${userId} denied access to webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - logger.info(`[${requestId}] Successfully retrieved webhook: ${id}`) + logger.info(`Successfully retrieved webhook: ${id}`) return NextResponse.json({ webhook: webhooks[0] }, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error fetching webhook`, error) + logger.error('Error fetching webhook', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -92,8 +90,6 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(updateWebhookContract, request, context) if (!parsed.success) return parsed.response @@ -101,7 +97,7 @@ export const PATCH = withRouteHandler( const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized webhook update attempt`) + logger.warn('Unauthorized webhook update attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -123,7 +119,7 @@ export const PATCH = withRouteHandler( .limit(1) if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${id}`) + logger.warn(`Webhook not found: ${id}`) return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) } @@ -136,7 +132,7 @@ export const PATCH = withRouteHandler( const canModify = authorization.allowed if (!canModify) { - logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`) + logger.warn(`User ${userId} denied permission to modify webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } await assertWorkflowMutable(webhookData.workflow.id) @@ -176,7 +172,7 @@ export const PATCH = withRouteHandler( } if (Object.keys(setClause).length === 0) { - logger.info(`[${requestId}] No-op webhook PATCH (no field changes): ${id}`) + logger.info(`No-op webhook PATCH (no field changes): ${id}`) return NextResponse.json({ webhook: webhooks[0].webhook }, { status: 200 }) } @@ -187,14 +183,14 @@ export const PATCH = withRouteHandler( .where(eq(webhook.id, id)) .returning() - logger.info(`[${requestId}] Successfully updated webhook: ${id}`) + logger.info(`Successfully updated webhook: ${id}`) return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 }) } catch (error) { if (error instanceof WorkflowLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error updating webhook`, error) + logger.error('Error updating webhook', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -212,7 +208,7 @@ export const DELETE = withRouteHandler( const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized webhook deletion attempt`) + logger.warn('Unauthorized webhook deletion attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -233,7 +229,7 @@ export const DELETE = withRouteHandler( .limit(1) if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${id}`) + logger.warn(`Webhook not found: ${id}`) return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) } @@ -247,7 +243,7 @@ export const DELETE = withRouteHandler( const canDelete = authorization.allowed if (!canDelete) { - logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`) + logger.warn(`User ${userId} denied permission to delete webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } await assertWorkflowMutable(webhookData.workflow.id) @@ -266,7 +262,7 @@ export const DELETE = withRouteHandler( // Telemetry should not fail the operation } - logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + logger.info(`Successfully deleted webhook: ${id}`) recordAudit({ workspaceId: webhookData.workflow.workspaceId || null, @@ -306,7 +302,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error deleting webhook`, { + logger.error('Error deleting webhook', { error: error.message, stack: error.stack, }) diff --git a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts index ba715ab2ee8..5a7346c59c6 100644 --- a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts +++ b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts @@ -13,7 +13,7 @@ export const maxDuration = 300 // Allow up to 5 minutes for cleanup export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() - logger.info(`Idempotency cleanup triggered (${requestId})`) + logger.info('Idempotency cleanup triggered') try { const authError = verifyCronAuth(request, 'Idempotency key cleanup') @@ -45,7 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - logger.error(`Error during idempotency cleanup (${requestId}):`, error) + logger.error('Error during idempotency cleanup:', error) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts index 8c09260dbb9..874062e980d 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { webhookPollingContract } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollProvider, VALID_POLLING_PROVIDERS } from '@/lib/webhooks/polling' @@ -20,7 +20,7 @@ export const maxDuration = 180 export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => { - const requestId = generateShortId() + const requestId = generateRequestId() let provider: string | undefined try { @@ -75,7 +75,7 @@ export const GET = withRouteHandler( ) } catch (error) { const providerLabel = provider ?? 'webhook' - logger.error(`Error during ${providerLabel} polling (${requestId}):`, error) + logger.error(`Error during ${providerLabel} polling:`, error) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts index 518bde8b878..636173abf51 100644 --- a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts @@ -83,7 +83,7 @@ export const POST = withRouteHandler( json.length === 0 || json.length > QUICKBOOKS_WEBHOOK_MAX_EVENTS ) { - logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`) + logger.warn('Invalid QuickBooks webhook envelope') return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 }) } @@ -95,7 +95,7 @@ export const POST = withRouteHandler( else droppedCount += 1 } if (droppedCount > 0) { - logger.warn(`[${requestId}] Dropped unmodelled QuickBooks webhook events`, { + logger.warn('Dropped unmodelled QuickBooks webhook events', { droppedCount, eventCount: json.length, }) @@ -112,13 +112,13 @@ export const POST = withRouteHandler( receivedAt, } const jobId = await enqueueQuickBooksWebhookIngress(payload) - logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, { + logger.info('Accepted QuickBooks webhook delivery', { eventCount: events.length, jobId, }) return NextResponse.json({ ok: true }) } catch (error) { - logger.error(`[${requestId}] QuickBooks webhook ingress error`, { + logger.error('QuickBooks webhook ingress error', { error: getErrorMessage(error, 'Unknown error'), }) return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 22468160cb5..dc93062b21f 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -62,7 +62,8 @@ async function revertSavedWebhook( updatedAt: existingWebhook.updatedAt, }) .where(eq(webhook.id, savedWebhook.id)) - logger.info(`[${requestId}] Restored previous webhook configuration after failed re-save`, { + logger.info('Restored previous webhook configuration after failed re-save', { + requestId, webhookId: savedWebhook.id, }) return @@ -73,12 +74,10 @@ async function revertSavedWebhook( // Get all webhooks for the current user export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized webhooks access attempt`) + logger.warn('Unauthorized webhooks access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -96,7 +95,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .limit(1) if (!wf.length) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + logger.warn(`Workflow not found: ${workflowId}`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } @@ -110,7 +109,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!canRead) { logger.warn( - `[${requestId}] User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}` + `User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}` ) return NextResponse.json({ webhooks: [] }, { status: 200 }) } @@ -146,7 +145,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .orderBy(desc(webhook.updatedAt)) logger.info( - `[${requestId}] Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}` + `Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}` ) return NextResponse.json({ webhooks }, { status: 200 }) } @@ -174,10 +173,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) .where(and(inArray(workflow.workspaceId, workspaceIds), isNull(webhook.archivedAt))) - logger.info(`[${requestId}] Retrieved ${webhooks.length} workspace-accessible webhooks`) + logger.info(`Retrieved ${webhooks.length} workspace-accessible webhooks`) return NextResponse.json({ webhooks }, { status: 200 }) } catch (error) { - logger.error(`[${requestId}] Error fetching webhooks`, error) + logger.error('Error fetching webhooks', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) @@ -189,7 +188,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = session?.user?.id if (!userId) { - logger.warn(`[${requestId}] Unauthorized webhook creation attempt`) + logger.warn('Unauthorized webhook creation attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -203,7 +202,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Validate input if (!workflowId) { - logger.warn(`[${requestId}] Missing required fields for webhook creation`, { + logger.warn('Missing required fields for webhook creation', { hasWorkflowId: !!workflowId, hasPath: !!path, }) @@ -251,19 +250,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (existingForBlock.length > 0) { finalPath = existingForBlock[0].path ?? '' - logger.info( - `[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}` - ) + logger.info(`Reusing existing generated path for ${provider} trigger: ${finalPath}`) } } // If still no path, generate a new dummy path (first-time save) if (!finalPath || finalPath.trim() === '') { finalPath = `${provider}-${generateId()}` - logger.info(`[${requestId}] Generated webhook path for ${provider} trigger: ${finalPath}`) + logger.info(`Generated webhook path for ${provider} trigger: ${finalPath}`) } } else { - logger.warn(`[${requestId}] Missing path for webhook creation`, { + logger.warn('Missing path for webhook creation', { hasWorkflowId: !!workflowId, hasPath: !!path, }) @@ -291,7 +288,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .limit(1) if (workflowData.length === 0) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + logger.warn(`Workflow not found: ${workflowId}`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } @@ -305,9 +302,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const canModify = authorization.allowed if (!canModify) { - logger.warn( - `[${requestId}] User ${userId} denied permission to modify webhook for workflow ${workflowId}` - ) + logger.warn(`User ${userId} denied permission to modify webhook for workflow ${workflowId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } await assertWorkflowMutable(workflowId) @@ -347,7 +342,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workflowId, }) if (conflictingOwner) { - logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`) + logger.warn(`Webhook path conflict: ${finalPath}`) return NextResponse.json( { error: 'Webhook path already exists.', code: 'PATH_EXISTS' }, { status: 409 } @@ -445,7 +440,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : false if (withheld) { logger.warn( - `[${requestId}] Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`, + `Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`, { userId, workflowId, @@ -500,7 +495,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { credentialId, workflowId } ) if (!credentialAccess.ok) { - logger.warn(`[${requestId}] Webhook credential reference denied`, { + logger.warn('Webhook credential reference denied', { userId, workflowId, credentialId, @@ -576,11 +571,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (err) { const isConfigurationError = err instanceof WebhookDeploymentConfigurationError if (isConfigurationError) { - logger.warn(`[${requestId}] Rejected webhook trigger configuration`, { + logger.warn('Rejected webhook trigger configuration', { error: err.message, }) } else { - logger.error(`[${requestId}] Error creating external webhook subscription`, err) + logger.error('Error creating external webhook subscription', err) } return NextResponse.json( { @@ -597,7 +592,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { if (targetWebhookId) { - logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, { + logger.info(`Updating existing webhook for path: ${finalPath}`, { webhookId: targetWebhookId, provider, hasCredentialId: !!(configToSave as any)?.credentialId, @@ -618,14 +613,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .where(eq(webhook.id, targetWebhookId)) .returning() savedWebhook = updatedResult[0] - logger.info(`[${requestId}] Webhook updated successfully`, { + logger.info('Webhook updated successfully', { webhookId: savedWebhook.id, savedProviderConfig: savedWebhook.providerConfig, }) } else { // Create a new webhook const webhookId = generateShortId() - logger.info(`[${requestId}] Creating new webhook with ID: ${webhookId}`) + logger.info(`Creating new webhook with ID: ${webhookId}`) const newResult = await db .insert(webhook) .values({ @@ -647,7 +642,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } catch (dbError) { if (externalSubscriptionCreated) { - logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError) + logger.error('DB save failed, cleaning up external subscription', dbError) try { await cleanupExternalWebhook( createTempWebhookData(omit(configToSave, ['previousSubscription'])), @@ -656,7 +651,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } catch (cleanupError) { logger.error( - `[${requestId}] Failed to cleanup external subscription after DB save failure`, + 'Failed to cleanup external subscription after DB save failure', cleanupError ) } @@ -712,7 +707,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId) } catch (cleanupError) { logger.warn( - `[${requestId}] Failed to cleanup previous external webhook subscription ${existingWebhook.id}`, + `Failed to cleanup previous external webhook subscription ${existingWebhook.id}`, cleanupError ) } @@ -721,9 +716,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (savedWebhook) { const pollingHandler = getProviderHandler(provider) if (pollingHandler.configurePolling) { - logger.info( - `[${requestId}] ${provider} provider detected. Setting up polling configuration.` - ) + logger.info(`${provider} provider detected. Setting up polling configuration.`) try { const success = await pollingHandler.configurePolling({ webhook: savedWebhook, @@ -735,9 +728,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!success) { - logger.error( - `[${requestId}] Failed to configure ${provider} polling, rolling back webhook` - ) + logger.error(`Failed to configure ${provider} polling, rolling back webhook`) await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { @@ -748,10 +739,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - logger.info(`[${requestId}] Successfully configured ${provider} polling`) + logger.info(`Successfully configured ${provider} polling`) } catch (err) { logger.error( - `[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`, + `Error setting up ${provider} webhook configuration, rolling back webhook`, err ) await revertSavedWebhook(savedWebhook, existingWebhook, requestId) @@ -818,7 +809,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error creating/updating webhook`, { + logger.error('Error creating/updating webhook', { message: error.message, stack: error.stack, }) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 85b87c35b41..c967bb7a369 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -171,7 +171,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise { rawBody = await readTikTokBody(request) } catch (bodyError) { if (isPayloadSizeLimitError(bodyError)) { - logger.warn(`[${requestId}] Rejected oversized TikTok webhook body`, { + logger.warn('Rejected oversized TikTok webhook body', { maxBytes: WEBHOOK_MAX_BODY_BYTES, observedBytes: bodyError.observedBytes, }) @@ -74,14 +74,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { parsedJson = rawBody ? JSON.parse(rawBody) : {} } catch { - logger.warn(`[${requestId}] TikTok webhook body is not valid JSON`) + logger.warn('TikTok webhook body is not valid JSON') // Ack to avoid retry storms on malformed payloads after a valid signature. return NextResponse.json({ ok: true }) } const envelopeResult = tiktokWebhookEnvelopeSchema.safeParse(parsedJson) if (!envelopeResult.success) { - logger.warn(`[${requestId}] Invalid TikTok webhook envelope`, { + logger.warn('Invalid TikTok webhook envelope', { issues: envelopeResult.error.issues, }) return NextResponse.json({ ok: true }) @@ -89,7 +89,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const envelope = envelopeResult.data if (!env.TIKTOK_CLIENT_ID || envelope.client_key !== env.TIKTOK_CLIENT_ID) { - logger.warn(`[${requestId}] TikTok webhook client_key does not match configured app`) + logger.warn('TikTok webhook client_key does not match configured app') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -107,7 +107,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (result.outcome === 'failed') failed += 1 } - logger.info(`[${requestId}] Processed TikTok webhook delivery`, { + logger.info('Processed TikTok webhook delivery', { dispatched, failed, event: envelope.event, @@ -121,7 +121,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ ok: true }) } catch (error) { - logger.error(`[${requestId}] TikTok webhook processing error`, { + logger.error('TikTok webhook processing error', { error: getErrorMessage(error, 'Unknown error'), }) return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 }) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index b201908cfc3..2e35e7f11ab 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -179,7 +179,7 @@ async function handleWebhookDelivery( ) if (allWebhooksForPath.length > 0 && pathWebhooks.length === 0) { - logger.warn(`[${requestId}] Rejected HTTP delivery to non-path trigger: ${path}`) + logger.warn(`Rejected HTTP delivery to non-path trigger: ${path}`, { requestId }) return notDeliverableResponse(request.method) } @@ -189,7 +189,8 @@ async function handleWebhookDelivery( if (pathWebhooks.length > 0 && webhooksForPath.length === 0) { logger.warn( - `[${requestId}] Rejected ${request.method} delivery to path ${path}: no trigger on this path accepts that method` + `Rejected ${request.method} delivery to path ${path}: no trigger on this path accepts that method`, + { requestId } ) return methodNotAllowedResponse() } @@ -205,7 +206,7 @@ async function handleWebhookDelivery( return verificationResponse } - logger.debug(`[${requestId}] Webhook or workflow not found for path: ${path}`) + logger.debug(`Webhook or workflow not found for path: ${path}`, { requestId }) return notDeliverableResponse(request.method) } @@ -293,9 +294,9 @@ async function handleWebhookDelivery( { status: 500 } ) if (dispatchTargetCount > 1) { - logger.error( - `[${requestId}] Webhook ${foundWebhook.id} has no provider, continuing to next` - ) + logger.error(`Webhook ${foundWebhook.id} has no provider, continuing to next`, { + requestId, + }) continue } return missingProviderResponse @@ -310,7 +311,7 @@ async function handleWebhookDelivery( ) if (authError) { if (dispatchTargetCount > 1) { - logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`) + logger.warn(`Auth failed for webhook ${foundWebhook.id}, continuing to next`, { requestId }) continue } return authError @@ -346,10 +347,11 @@ async function handleWebhookDelivery( if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') { if (dispatchTargetCount > 1) { - logger.warn( - `[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`, - { reason: dispatchResult.reason, status: dispatchResult.response.status } - ) + logger.warn(`Webhook dispatch failed for ${foundWebhook.id}, continuing to next`, { + requestId, + reason: dispatchResult.reason, + status: dispatchResult.response.status, + }) if (dispatchResult.outcome === 'failed') { failures.push(dispatchResult.response) } else { @@ -381,7 +383,7 @@ async function handleWebhookDelivery( } // For multiple webhooks, return success if at least one succeeded - logger.info(`[${requestId}] Processed ${responses.length} webhooks for path: ${path}`) + logger.info(`Processed ${responses.length} webhooks for path: ${path}`, { requestId }) return NextResponse.json({ success: true, webhooksProcessed: responses.length, diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 387047da880..94bc9fea409 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -9,7 +9,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { applyAutoLayout } from '@/lib/workflows/autolayout' import { @@ -32,14 +31,13 @@ const logger = createLogger('AutoLayoutAPI') */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const startTime = Date.now() const { id: workflowId } = await context.params try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) + logger.warn(`Unauthorized autolayout attempt for workflow ${workflowId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -49,7 +47,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const layoutOptions = parsed.data.body - logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { + logger.info(`Processing autolayout request for workflow ${workflowId}`, { userId, }) @@ -61,16 +59,14 @@ export const POST = withRouteHandler( const workflowData = authorization.workflow if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`) + logger.warn(`Workflow ${workflowId} not found for autolayout`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const canUpdate = authorization.allowed if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}` - ) + logger.warn(`User ${userId} denied permission to autolayout workflow ${workflowId}`) return NextResponse.json( { error: authorization.message || 'Access denied' }, { status: authorization.status || 403 } @@ -82,7 +78,7 @@ export const POST = withRouteHandler( let currentWorkflowData: NormalizedWorkflowData | null if (layoutOptions.blocks && layoutOptions.edges) { - logger.info(`[${requestId}] Using provided blocks with live measurements`) + logger.info('Using provided blocks with live measurements') currentWorkflowData = { blocks: layoutOptions.blocks, edges: layoutOptions.edges, @@ -91,12 +87,12 @@ export const POST = withRouteHandler( isFromNormalizedTables: false, } } else { - logger.info(`[${requestId}] Loading blocks from database`) + logger.info('Loading blocks from database') currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) } if (!currentWorkflowData) { - logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) + logger.error(`Could not load workflow ${workflowId} for autolayout`) return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) } @@ -118,7 +114,7 @@ export const POST = withRouteHandler( ) if (!layoutResult.success || !layoutResult.blocks) { - logger.error(`[${requestId}] Auto layout failed:`, { + logger.error('Auto layout failed:', { error: layoutResult.error, }) return NextResponse.json( @@ -133,7 +129,7 @@ export const POST = withRouteHandler( const elapsed = Date.now() - startTime const blockCount = Object.keys(layoutResult.blocks).length - logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, { + logger.info(`Autolayout completed successfully in ${elapsed}ms`, { blockCount, workflowId, }) @@ -154,7 +150,7 @@ export const POST = withRouteHandler( const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error) + logger.error(`Autolayout failed after ${elapsed}ms:`, error) return NextResponse.json( { error: 'Autolayout failed', diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 20bf893ee71..d95612287c8 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -88,10 +88,10 @@ export const PATCH = withRouteHandler( }) if (name !== undefined || description !== undefined) { - logger.info( - `[${requestId}] Updated deployment version ${version} metadata during activation`, - { name, description } - ) + logger.info(`Updated deployment version ${version} metadata during activation`, { + name, + description, + }) } return createSuccessResponse({ @@ -111,7 +111,7 @@ export const PATCH = withRouteHandler( request, }) - logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, { + logger.info(`Updated deployment version ${version} for workflow ${id}`, { name, description, }) @@ -130,7 +130,7 @@ export const PATCH = withRouteHandler( statusForOrchestrationError(orchestrationError.code) ) } - logger.error(`[${requestId}] Error updating deployment version`, { error }) + logger.error('Error updating deployment version', { error }) return createCodedErrorResponse('Failed to update deployment version', 500) } } diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index beba4a3f3ab..bde8f9900bd 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -22,9 +22,7 @@ export const POST = withRouteHandler( const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn( - `[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}` - ) + logger.warn(`Unauthorized workflow duplication attempt for ${sourceWorkflowId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -34,7 +32,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { name, description, workspaceId, folderId, newId } = parsed.data.body - logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) + logger.info(`Duplicating workflow ${sourceWorkflowId} for user ${userId}`) const result = await duplicateWorkflow({ sourceWorkflowId, @@ -70,7 +68,7 @@ export const POST = withRouteHandler( const elapsed = Date.now() - startTime logger.info( - `[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms` + `Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms` ) recordAudit({ @@ -99,20 +97,18 @@ export const POST = withRouteHandler( } if (error.message === 'Source workflow not found') { - logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) + logger.warn(`Source workflow ${sourceWorkflowId} not found`) return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 }) } if (error.message === 'Source workflow not found or access denied') { - logger.warn( - `[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}` - ) + logger.warn(`User ${userId} denied access to source workflow ${sourceWorkflowId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } if (error.message === 'Cross-workspace workflow duplication is not supported') { logger.warn( - `[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}` + `User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}` ) return NextResponse.json({ error: error.message }, { status: 400 }) } @@ -127,10 +123,7 @@ export const POST = withRouteHandler( } const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`, - error - ) + logger.error(`Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`, error) return NextResponse.json({ error: 'Failed to duplicate workflow' }, { status: 500 }) } } diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index b42dcdb9ce4..d95dee77ce0 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -66,7 +66,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: result.error }, { status }) } - logger.info(`[${requestId}] Restored workflow ${workflowId}`) + logger.info(`Restored workflow ${workflowId}`) captureServerEvent( auth.userId, @@ -81,7 +81,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) + logger.error(`Error restoring workflow ${workflowId}`, error) return NextResponse.json( { error: getErrorMessage(error, 'Internal server error') }, { status: 500 } diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 27ef1e72c2d..d5b5931b043 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -99,7 +99,7 @@ export const PUT = withRouteHandler( try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`) + logger.warn(`Unauthorized state update attempt for workflow ${workflowId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -125,15 +125,12 @@ export const PUT = withRouteHandler( } const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) + logger.info(`Successfully saved workflow ${workflowId} state in ${elapsed}ms`) return NextResponse.json({ success: true, warnings: result.warnings }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, - error - ) + logger.error(`Error saving workflow ${workflowId} state after ${elapsed}ms`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 09f6e6fb694..51171b6c56b 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -13,7 +13,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { workflowVariablesContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Variable } from '@/stores/variables/types' @@ -21,13 +20,12 @@ const logger = createLogger('WorkflowVariablesAPI') export const POST = withRouteHandler( async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workflowId = (await context.params).id try { const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`) + logger.warn('Unauthorized workflow variables update attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -40,14 +38,14 @@ export const POST = withRouteHandler( const workflowData = authorization.workflow if (!workflowData) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + logger.warn(`Workflow not found: ${workflowId}`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const isAuthorized = authorization.allowed if (!isAuthorized) { logger.warn( - `[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission` + `User ${userId} attempted to update variables for workflow ${workflowId} without permission` ) return NextResponse.json( { error: authorization.message || 'Access denied' }, @@ -99,7 +97,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error updating workflow variables`, error) + logger.error('Error updating workflow variables', error) return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 }) } } @@ -107,13 +105,12 @@ export const POST = withRouteHandler( export const GET = withRouteHandler( async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workflowId = (await params).id try { const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`) + logger.warn('Unauthorized workflow variables access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -126,14 +123,14 @@ export const GET = withRouteHandler( const workflowData = authorization.workflow if (!workflowData) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + logger.warn(`Workflow not found: ${workflowId}`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const isAuthorized = authorization.allowed if (!isAuthorized) { logger.warn( - `[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission` + `User ${userId} attempted to access variables for workflow ${workflowId} without permission` ) return NextResponse.json( { error: authorization.message || 'Access denied' }, @@ -162,7 +159,7 @@ export const GET = withRouteHandler( } ) } catch (error) { - logger.error(`[${requestId}] Workflow variables fetch error`, error) + logger.error('Workflow variables fetch error', error) const errorMessage = getErrorMessage(error, 'Unknown error') return NextResponse.json({ error: errorMessage }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index 45ab9f9addd..9a8a28731a5 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -14,7 +14,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -22,10 +21,9 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowReorderAPI') export const PUT = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized reorder attempt`) + logger.warn('Unauthorized reorder attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -37,9 +35,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission || permission === 'read') { - logger.warn( - `[${requestId}] User ${userId} lacks write permission for workspace ${workspaceId}` - ) + logger.warn(`User ${userId} lacks write permission for workspace ${workspaceId}`) return NextResponse.json({ error: 'Write access required' }, { status: 403 }) } @@ -80,9 +76,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } }) - logger.info( - `[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}` - ) + logger.info(`Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`) await notifyWorkspaceWorkflowsChanged(workspaceId) @@ -96,7 +90,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error reordering workflows`, error) + logger.error('Error reordering workflows', error) return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 05d94a31b1d..6758f56c55d 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -15,7 +15,6 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowAPI') export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() const startTime = Date.now() const url = new URL(request.url) const query = workflowListQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries())) @@ -30,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow access attempt`) + logger.warn('Unauthorized workflow access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -39,9 +38,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const wsExists = await workspaceExists(workspaceId) if (!wsExists) { - logger.warn( - `[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}` - ) + logger.warn(`Attempt to fetch workflows for non-existent workspace: ${workspaceId}`) return NextResponse.json( { error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' }, { status: 404 } @@ -52,7 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!userRole) { logger.warn( - `[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership` + `User ${userId} attempted to access workspace ${workspaceId} without membership` ) return NextResponse.json( { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, @@ -66,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ data: workflows }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) + logger.error(`Workflow fetch error after ${elapsed}ms`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) @@ -76,7 +73,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow creation attempt`) + logger.warn('Unauthorized workflow creation attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = auth.userId @@ -95,7 +92,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } = parsed.data.body if (!workspaceId) { - logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`) + logger.warn('Workflow creation blocked: missing workspaceId') return NextResponse.json( { error: @@ -109,7 +106,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (!workspacePermission || workspacePermission === 'read') { logger.warn( - `[${requestId}] User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions` + `User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions` ) return NextResponse.json( { error: 'Write or Admin access required to create workflows in this workspace' }, @@ -152,9 +149,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { // Silently fail }) - logger.info( - `[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks` - ) + logger.info(`Successfully created workflow ${createdWorkflow.id} with default blocks`) captureServerEvent( userId, @@ -186,7 +181,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) } - logger.error(`[${requestId}] Error creating workflow`, error) + logger.error('Error creating workflow', error) return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/workspace-events/poll/route.ts b/apps/sim/app/api/workspace-events/poll/route.ts index 7cde86e0d49..a3d63ccbf58 100644 --- a/apps/sim/app/api/workspace-events/poll/route.ts +++ b/apps/sim/app/api/workspace-events/poll/route.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { noInputSchema } from '@/lib/api/contracts/primitives' import { validationErrorResponse } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollNoActivityEvents } from '@/lib/workspace-events/no-activity' @@ -18,8 +18,8 @@ const LOCK_KEY = 'workspace-events-no-activity-poll-lock' const LOCK_TTL_SECONDS = 120 export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateShortId() - logger.info(`Workspace events no-activity polling triggered (${requestId})`) + const requestId = generateRequestId() + logger.info('Workspace events no-activity polling triggered') const queryValidation = noInputSchema.safeParse( Object.fromEntries(request.nextUrl.searchParams.entries()) ) @@ -65,7 +65,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { status: 202 } ) } catch (error) { - logger.error(`Error during workspace events polling (${requestId}):`, error) + logger.error('Error during workspace events polling:', error) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 4ef2865e2f4..2dc2ea4ffdf 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -24,7 +24,6 @@ import { workspaceApiKeyErrorPolicy } from '@/lib/api-key/management-error-polic import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { capabilityRefusal, @@ -68,13 +67,12 @@ export const GET = defineInternalJsonRoute({ */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workspaceId = (await context.params).id try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`) + logger.warn('Unauthorized workspace API key creation attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -117,13 +115,13 @@ export const POST = withRouteHandler( } ) - logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) + logger.info(`Created workspace API key: ${name} in workspace ${workspaceId}`) return NextResponse.json({ key: result.key, }) } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API key POST error`, error) + logger.error('Workspace API key POST error', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to create workspace API key') }, { status: 500 } @@ -134,13 +132,12 @@ export const POST = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workspaceId = (await context.params).id try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`) + logger.warn('Unauthorized workspace API key deletion attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -183,9 +180,7 @@ export const DELETE = withRouteHandler( // Telemetry should not fail the operation } - logger.info( - `[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}` - ) + logger.info(`Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`) recordAudit({ workspaceId, @@ -201,7 +196,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true, deletedCount }) } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API key DELETE error`, error) + logger.error('Workspace API key DELETE error', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to delete workspace API keys') }, { status: 500 } diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 96438089796..485c9f4a905 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -28,7 +28,6 @@ import { import { workspaceByokErrorPolicy } from '@/lib/api-key/byok-error-policy' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import { captureServerEvent } from '@/lib/posthog/server' @@ -73,13 +72,12 @@ export const GET = defineInternalJsonRoute({ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workspaceId = (await context.params).id try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized BYOK key creation attempt`) + logger.warn('Unauthorized BYOK key creation attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -127,7 +125,7 @@ export const POST = withRouteHandler( }) .where(eq(workspaceBYOKKeys.id, existingKey.id)) - logger.info(`[${requestId}] Updated BYOK key for ${providerId} in workspace ${workspaceId}`) + logger.info(`Updated BYOK key for ${providerId} in workspace ${workspaceId}`) recordAudit({ workspaceId, @@ -208,7 +206,7 @@ export const POST = withRouteHandler( ) } - logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`) + logger.info(`Created BYOK key for ${providerId} in workspace ${workspaceId}`) captureServerEvent( userId, @@ -242,7 +240,7 @@ export const POST = withRouteHandler( }, }) } catch (error: unknown) { - logger.error(`[${requestId}] BYOK key POST error`, error) + logger.error('BYOK key POST error', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to save BYOK key') }, { status: 500 } diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 1ed68d7adb2..8b8d51fad1d 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -13,7 +13,6 @@ import { import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { lockWorkspaceEnvMap } from '@/lib/credentials/env-locks' import { @@ -125,13 +124,12 @@ async function maskPersonalEnvForViewer({ export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workspaceId = (await params).id try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace env access attempt`) + logger.warn('Unauthorized workspace env access attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -183,7 +181,7 @@ export const GET = withRouteHandler( { status: 200 } ) } catch (error) { - logger.error(`[${requestId}] Workspace env GET error`, error) + logger.error('Workspace env GET error', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to load environment') }, { status: 500 } @@ -200,13 +198,12 @@ export const GET = withRouteHandler( */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workspaceId = (await context.params).id try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace env update attempt`) + logger.warn('Unauthorized workspace env update attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -236,7 +233,7 @@ export const PUT = withRouteHandler( const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key) const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k)) if (forbiddenExisting.length > 0) { - logger.warn(`[${requestId}] Workspace env update denied`, { + logger.warn('Workspace env update denied', { workspaceId, userId, reason: 'not-secret-admin', @@ -252,7 +249,7 @@ export const PUT = withRouteHandler( permission !== 'admin' && permission !== 'write' ) { - logger.warn(`[${requestId}] Workspace env update denied`, { + logger.warn('Workspace env update denied', { workspaceId, userId, reason: 'write-access-required', @@ -343,7 +340,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { - logger.error(`[${requestId}] Workspace env PUT error`, error) + logger.error('Workspace env PUT error', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to update environment') }, { status: 500 } @@ -359,13 +356,12 @@ export const PUT = withRouteHandler( */ export const DELETE = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() const workspaceId = (await context.params).id try { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`) + logger.warn('Unauthorized workspace env delete attempt') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -391,7 +387,7 @@ export const DELETE = withRouteHandler( const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key) const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k)) if (forbiddenExisting.length > 0) { - logger.warn(`[${requestId}] Workspace env delete denied`, { + logger.warn('Workspace env delete denied', { workspaceId, userId, reason: 'not-secret-admin', @@ -403,7 +399,7 @@ export const DELETE = withRouteHandler( ) } if (keys.some((k) => !knownKeys.has(k)) && permission !== 'admin' && permission !== 'write') { - logger.warn(`[${requestId}] Workspace env delete denied`, { + logger.warn('Workspace env delete denied', { workspaceId, userId, reason: 'write-access-required', @@ -481,7 +477,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { - logger.error(`[${requestId}] Workspace env DELETE error`, error) + logger.error('Workspace env DELETE error', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to remove environment keys') }, { status: 500 } diff --git a/apps/sim/app/api/workspaces/route.test.ts b/apps/sim/app/api/workspaces/route.test.ts index c275e42beb6..ce44469ac77 100644 --- a/apps/sim/app/api/workspaces/route.test.ts +++ b/apps/sim/app/api/workspaces/route.test.ts @@ -16,8 +16,9 @@ import { } from '@sim/testing/mocks/workspaces-policy.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCreateWorkspace } = vi.hoisted(() => ({ +const { mockCreateWorkspace, mockResolveRolledOutOrganizationId } = vi.hoisted(() => ({ mockCreateWorkspace: vi.fn(), + mockResolveRolledOutOrganizationId: vi.fn(), })) vi.mock('@/lib/auth/session-response', () => ({ @@ -32,6 +33,10 @@ vi.mock('@/lib/workspaces/list', () => ({ listWorkspacesForViewer: vi.fn(), })) +vi.mock('@/lib/navigation/resolve-app-entry', () => ({ + resolveRolledOutOrganizationId: mockResolveRolledOutOrganizationId, +})) + vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@sim/audit', () => auditMock) @@ -99,3 +104,43 @@ describe('POST /api/workspaces capability refusal', () => { await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) }) }) + +describe('GET /api/workspaces blocked viewer forwarding', () => { + const blockedList = { + workspaces: [], + lastActiveWorkspaceId: null, + pinnedWorkspaceIds: [], + creationPolicy: { canCreate: false }, + } + const routeContext = { params: Promise.resolve({}) } + const listRequest = () => + createMockRequest('GET', undefined, {}, 'http://localhost/api/workspaces') + + beforeEach(() => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'A', email: 'a@example.com' }, + }) + vi.mocked(listWorkspacesForViewer).mockResolvedValue( + blockedList as unknown as Awaited> + ) + }) + + it('names the organization that can serve a viewer with nothing to pick', async () => { + mockResolveRolledOutOrganizationId.mockResolvedValue('org-1') + const response = await GET(listRequest(), routeContext) + expect((await response.json()).forwardOrganizationId).toBe('org-1') + }) + + it('leaves the blocked picker in place when no organization can serve them', async () => { + mockResolveRolledOutOrganizationId.mockResolvedValue(null) + const response = await GET(listRequest(), routeContext) + expect(await response.json()).not.toHaveProperty('forwardOrganizationId') + }) + + it('still returns the list when the organization lookup fails', async () => { + mockResolveRolledOutOrganizationId.mockRejectedValue(new Error('down')) + const response = await GET(listRequest(), routeContext) + expect(response.status).toBe(200) + expect(await response.json()).not.toHaveProperty('forwardOrganizationId') + }) +}) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index f2eebe797de..28ec82d748c 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { type WorkspaceMode, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWorkspacesQuerySchema } from '@/lib/api/contracts' @@ -11,6 +11,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveRolledOutOrganizationId } from '@/lib/navigation/resolve-app-entry' import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { createWorkspace } from '@/lib/workspaces/create' @@ -24,6 +25,20 @@ import { const logger = createLogger('Workspaces') +/** A failed lookup leaves the viewer on the blocked picker rather than failing the list. */ +async function resolveForwardOrganizationId(session: { + user: { id: string } +}): Promise { + try { + return (await resolveRolledOutOrganizationId(session)) ?? undefined + } catch (error) { + logger.warn('Could not resolve an organization to forward a blocked viewer to', { + error: getErrorMessage(error), + }) + return undefined + } +} + // Get all workspaces for the current user export const GET = withRouteHandler(async (request: Request) => { const session = await getSession() @@ -58,6 +73,7 @@ export const GET = withRouteHandler(async (request: Request) => { lastActiveWorkspaceId, pinnedWorkspaceIds, creationPolicy, + forwardOrganizationId: await resolveForwardOrganizationId(session), }) } diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 9a207a92130..130cdbf3e74 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -227,7 +227,7 @@ export default async function CredentialGroupEnrollmentPage({ ) const visibleOptions = focusedOption ? [focusedOption] : activeOptions const focusedConnected = - focusedOption?.connections[0]?.status === 'connected' && + focusedOption?.connections.some((connection) => connection.status === 'connected') && (returnToSearch || connectedOptionId === focusedOption.id) const connectedOption = connectedOptionId ? activeOptions.find((option) => option.id === connectedOptionId) @@ -286,29 +286,34 @@ export default async function CredentialGroupEnrollmentPage({
{visibleOptions.map((option) => { const ProviderIcon = getCredentialGroupProviderService(option.provider).icon - const connection = option.connections[0] + const visibleConnections = option.connections.filter( + (connection) => connection.status !== 'revoked' + ) return ( } title={option.label} description={ - option.connections.length > 0 - ? option.connections.map((account) => ( - + visibleConnections.length > 0 + ? visibleConnections.map((account) => ( + {account.email} {account.status === 'connected' ? ' — Connected' - : ' — Reconnect required'} + : ' — Reconnect needed'} )) : 'Not connected' } actions={ - focusedConnected ? undefined : ( + focusedConnected && + visibleConnections.every( + (account) => account.status === 'connected' + ) ? undefined : ( 0} variant={focused ? 'primary' : undefined} /> ) @@ -328,7 +333,7 @@ export default async function CredentialGroupEnrollmentPage({ server.connection?.status === 'connected' ? 'Connected' : server.connection - ? 'Reconnect required' + ? 'Reconnect needed' : server.description || 'Not connected' } actions={ diff --git a/apps/sim/app/ingest/[[...path]]/route.test.ts b/apps/sim/app/ingest/[[...path]]/route.test.ts index 252bcb47b72..767dabfcad4 100644 --- a/apps/sim/app/ingest/[[...path]]/route.test.ts +++ b/apps/sim/app/ingest/[[...path]]/route.test.ts @@ -31,7 +31,7 @@ describe('PostHog ingest proxy', () => { const response = await POST(createIngestRequest(controller.signal)) expect(response.status).toBe(499) - expect(logger.error).not.toHaveBeenCalled() + expect(logger.warn).not.toHaveBeenCalled() }) it('reports an upstream failure while the client is still connected', async () => { @@ -40,7 +40,7 @@ describe('PostHog ingest proxy', () => { const response = await POST(createIngestRequest()) expect(response.status).toBe(502) - expect(logger.error).toHaveBeenCalledWith( + expect(logger.warn).toHaveBeenCalledWith( 'PostHog proxy error', expect.objectContaining({ error: 'The operation timed out.' }) ) diff --git a/apps/sim/app/ingest/[[...path]]/route.ts b/apps/sim/app/ingest/[[...path]]/route.ts index 2117b1bf77f..c760613da35 100644 --- a/apps/sim/app/ingest/[[...path]]/route.ts +++ b/apps/sim/app/ingest/[[...path]]/route.ts @@ -61,7 +61,7 @@ async function handler(request: NextRequest) { logger.info('Client closed PostHog proxy request', { method: request.method }) return new NextResponse(null, { status: 499 }) } - logger.error('PostHog proxy error', { + logger.warn('PostHog proxy error', { url, method: request.method, error: toError(error).message, diff --git a/apps/sim/app/marketplace/aws/aws-marketplace-onboarding.tsx b/apps/sim/app/marketplace/aws/aws-marketplace-onboarding.tsx new file mode 100644 index 00000000000..a1ac7d78420 --- /dev/null +++ b/apps/sim/app/marketplace/aws/aws-marketplace-onboarding.tsx @@ -0,0 +1,495 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { + AuthField, + AuthFormMessage, + AuthInput, + AuthSelect, + AuthTag, + AuthTextLink, +} from '@sim/auth-ui' +import { formatDate } from '@sim/utils/formatting' +import { useRouter } from 'next/navigation' +import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace' +import { client, useSession } from '@/lib/auth/client' +import { + AWS_MARKETPLACE_ONBOARDING_PATH, + AWS_MARKETPLACE_SETUP_TURNAROUND, + type AwsMarketplaceOnboardingError, +} from '@/lib/billing/aws-marketplace/onboarding' +import { organizationRoutes } from '@/lib/navigation/paths' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { InviteLayout, InviteStatusCard } from '@/app/invite/components' +import { useBrandConfig } from '@/ee/whitelabeling' +import { + useAwsMarketplaceClaim, + useAwsMarketplaceLicenses, + useLinkAwsMarketplaceLicense, + useRegisterAwsMarketplaceLicenseToAccount, + useSaveAwsMarketplaceClaimContact, +} from '@/hooks/queries/aws-marketplace' + +const RESTART_FROM_AWS = + 'Open your Sim subscription in AWS Marketplace and choose Set up your account.' + +const REGISTRATION_ERROR_COPY: Record< + AwsMarketplaceOnboardingError, + { title: string; description: string } +> = { + 'no-subscription': { + title: 'No active subscription found', + description: + 'AWS Marketplace has no active Sim subscription for this AWS account. Subscribe to Sim in AWS Marketplace, then choose Set up your account to return here.', + }, + 'not-started': { + title: 'Your agreement has not started', + description: + 'Come back to AWS Marketplace on your agreement start date and choose Set up your account.', + }, + invalid: { + title: 'This setup link did not work', + description: RESTART_FROM_AWS, + }, + unavailable: { + title: 'AWS Marketplace is not responding', + description: `We could not confirm your subscription with AWS. Try again in a few minutes: ${RESTART_FROM_AWS}`, + }, +} + +/** AWS's manual-setup guidance: being set up, wait to be contacted, turnaround, contact. */ +const BEING_SET_UP = `Your account is being set up. Please wait for us to contact you. We expect to finish ${AWS_MARKETPLACE_SETUP_TURNAROUND}.` + +const LICENSE_STATUS_LABELS = { + active: 'Active', + pending: 'Confirming with AWS', + inactive: 'Inactive', +} as const + +function authLink(path: '/login' | '/signup'): string { + return buildAuthCrossLink(path, { + callbackUrl: AWS_MARKETPLACE_ONBOARDING_PATH, + isInviteFlow: true, + }) +} + +interface SupportContactProps { + supportEmail: string | undefined +} + +/** AWS requires support contact options on the fulfillment landing page. */ +function SupportContact({ supportEmail }: SupportContactProps) { + if (!supportEmail) return null + return ( +

+ Questions? Email {supportEmail} +

+ ) +} + +/** Status for a buyer with no organization, who may not be able to open any billing page. */ +function AccountSubscriptionStatus() { + const { data: licenses } = useAwsMarketplaceLicenses(null) + if (!licenses?.length) return null + return ( +
    + {licenses.map((license) => ( +
  • + {LICENSE_STATUS_LABELS[license.status]} + {license.expiresAt && `, expires ${formatDate(new Date(license.expiresAt))}`} +
  • + ))} +
+ ) +} + +interface NoSetupInProgressProps { + supportContact: ReactNode + actions: Array<{ label: string; onClick: () => void }> +} + +/** A finished buyer who returns sees their account's status; anyone else, how to start. */ +function NoSetupInProgress({ supportContact, actions }: NoSetupInProgressProps) { + const { data: licenses, isPending } = useAwsMarketplaceLicenses(null) + if (isPending) { + return ( + + + + ) + } + if (licenses?.length) { + const underway = licenses.some((license) => license.status !== 'inactive') + return ( + + + + {supportContact} +
+ } + actions={actions} + /> + + ) + } + return ( + + + + ) +} + +interface AwsMarketplaceOnboardingProps { + registrationError: AwsMarketplaceOnboardingError | null + registrationDisabled: boolean +} + +export function AwsMarketplaceOnboarding({ + registrationError, + registrationDisabled, +}: AwsMarketplaceOnboardingProps) { + const router = useRouter() + const { data: session, isPending: sessionPending } = useSession() + const viewerId = session?.user?.id ?? null + const claimQuery = useAwsMarketplaceClaim(registrationError ? null : viewerId) + const linkLicense = useLinkAwsMarketplaceLicense() + const registerToAccount = useRegisterAwsMarketplaceLicenseToAccount() + const { supportEmail } = useBrandConfig() + const supportContact = + const returnHome = { label: 'Return to Home', onClick: () => router.push('/') } + + /** A revoked session's cookie would send `/login` straight to the app, losing the way back. */ + const goToAuth = async (path: '/login' | '/signup') => { + await client.signOut().catch(() => {}) + router.push(authLink(path)) + } + + if (registrationError) { + const copy = REGISTRATION_ERROR_COPY[registrationError] + return ( + + + + ) + } + + if (sessionPending) { + return ( + + + + ) + } + + if (!session?.user) { + return ( + + goToAuth('/signup') }]), + { label: 'I already have an account', onClick: () => goToAuth('/login') }, + ]} + /> + + ) + } + + if (linkLicense.isSuccess || registerToAccount.isSuccess) { + const organizationId = linkLicense.variables?.organizationId + return ( + + + {organizationId ? ( +

+ + View subscription status + +

+ ) : ( + + )} + {supportContact} + + } + /> +
+ ) + } + + if (claimQuery.isPending) { + return ( + + + + ) + } + + if (claimQuery.isError) { + return ( + + claimQuery.refetch() }, returnHome]} + /> + + ) + } + + const claim = claimQuery.data + if (claim.status === 'missing') { + return + } + + if (claim.status === 'linked') { + return ( + + + {claim.organizationId && ( +

+ + View subscription status + +

+ )} + {supportContact} + + } + actions={[returnHome]} + /> +
+ ) + } + + return ( + { + registerToAccount.reset() + linkLicense.mutate({ licenseId: claim.license.id, organizationId, ...contact }) + }} + onRegister={(contact) => { + linkLicense.reset() + registerToAccount.mutate({ licenseId: claim.license.id, ...contact }) + }} + onCancel={returnHome.onClick} + /> + ) +} + +interface BuyerContact { + contactEmail: string + companyName?: string +} + +interface ClaimCardProps { + claim: Extract + signedInAs: string + supportContact: ReactNode + linking: boolean + registering: boolean + error: string | null + onLink: (organizationId: string, contact: BuyerContact) => void + onRegister: (contact: BuyerContact) => void + onCancel: () => void +} + +/** + * Shows the buyer whose subscription this is and who is signed in, so a claim planted in someone + * else's browser is noticed. A confirmed license links to an organization the viewer administers, + * or registers to their account. While AWS is still confirming it, the buyer leaves contact + * details and asks again. + */ +function ClaimCard({ + claim, + signedInAs, + supportContact, + linking, + registering, + error, + onLink, + onRegister, + onCancel, +}: ClaimCardProps) { + const { license } = claim + const organizations = claim.status === 'ready' ? claim.organizations : [] + const saveContact = useSaveAwsMarketplaceClaimContact() + const [organizationId, setOrganizationId] = useState() + const [contactEmail, setContactEmail] = useState(license.contactEmail ?? signedInAs) + const [companyName, setCompanyName] = useState(license.companyName ?? '') + const contact: BuyerContact = { + contactEmail: contactEmail.trim(), + ...(companyName.trim() && { companyName: companyName.trim() }), + } + const hasContact = contact.contactEmail.length > 0 + const pending = linking || registering || saveContact.isPending + + const rows = [ + { label: 'AWS account', value: license.awsAccount }, + { label: 'Signed in as', value: signedInAs }, + { label: 'Plan', value: license.dimension ?? 'Not specified' }, + { label: 'Quantity', value: license.quantity?.toLocaleString() ?? 'Not specified' }, + { + label: 'Expires', + value: license.expiresAt ? formatDate(new Date(license.expiresAt)) : 'No expiry', + }, + ] + + const hasOrganizations = organizations.length > 0 + const actions = + claim.status === 'pending' + ? [ + { + label: 'Save and check again', + onClick: () => saveContact.mutate({ licenseId: license.id, ...contact }), + disabled: pending || !hasContact, + loading: saveContact.isPending, + }, + ] + : [ + ...(hasOrganizations + ? [ + { + label: 'Link subscription', + onClick: () => organizationId && onLink(organizationId, contact), + disabled: !organizationId || !hasContact || pending, + loading: linking, + }, + ] + : []), + { + label: hasOrganizations ? 'Register to my account instead' : 'Register to my account', + onClick: () => onRegister(contact), + disabled: pending || !hasContact, + loading: registering, + }, + ] + + const description = + claim.status === 'pending' + ? 'AWS is still confirming your subscription. Leave your contact details and check again in a few minutes.' + : hasOrganizations + ? `Choose the organization this subscription is for, or register it to your account. We expect to finish setting it up ${AWS_MARKETPLACE_SETUP_TURNAROUND}.` + : `Register this subscription to your account. We expect to finish setting it up ${AWS_MARKETPLACE_SETUP_TURNAROUND}.` + + return ( + + +
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
Status
+
+ {claim.status === 'pending' ? 'Confirming with AWS' : 'Active'} +
+
+
+ {hasOrganizations && ( + + ({ label: org.name, value: org.id }))} + value={organizationId} + onChange={setOrganizationId} + placeholder='Select an organization' + fullWidth + /> + + )} + + setContactEmail(event.target.value)} + required + /> + + + setCompanyName(event.target.value)} + /> + + {saveContact.isSuccess && claim.status === 'pending' && ( + + We saved your details. AWS is still confirming your subscription. + + )} + {(error ?? saveContact.error?.message) && ( + {error ?? saveContact.error?.message} + )} + {supportContact} + + } + actions={[...actions, { label: 'Not now', onClick: onCancel }]} + /> +
+ ) +} diff --git a/apps/sim/app/marketplace/aws/page.tsx b/apps/sim/app/marketplace/aws/page.tsx new file mode 100644 index 00000000000..2ad232d3ba5 --- /dev/null +++ b/apps/sim/app/marketplace/aws/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { AWS_MARKETPLACE_ONBOARDING_ERRORS } from '@/lib/billing/aws-marketplace/onboarding' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { AwsMarketplaceOnboarding } from '@/app/marketplace/aws/aws-marketplace-onboarding' + +export const metadata: Metadata = { + title: 'AWS Marketplace', + robots: { index: false }, +} + +export const dynamic = 'force-dynamic' + +interface AwsMarketplacePageProps { + searchParams: Promise<{ error?: string | string[] }> +} + +/** Where AWS Marketplace buyers land after registration, to link their subscription. */ +export default async function AwsMarketplacePage({ searchParams }: AwsMarketplacePageProps) { + if (!getAwsMarketplaceProductCode()) notFound() + const { error } = await searchParams + const registrationError = AWS_MARKETPLACE_ONBOARDING_ERRORS.find((code) => code === error) ?? null + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx index 6f44c352d39..f834c893055 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx @@ -18,7 +18,7 @@ import { useRouter } from 'next/navigation' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { getOrganizationSettingsHref } from '@/components/settings/navigation' import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' -import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface' +import type { OrganizationSurfaceContext } from '@/lib/organizations/surface' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' @@ -32,7 +32,7 @@ function getOrganizationInitial(name: string): string { } interface OrganizationHeaderProps { - organization: OrganizationSurfaceOrganization + organization: OrganizationSurfaceContext['organization'] /** Organization admins may change its logo and name. */ canEditIdentity: boolean canInviteMembers: boolean diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts index 3dee5e2faea..5824b847fde 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts @@ -1,3 +1,2 @@ export type { OrganizationChat } from './use-organization-chats' export { useOrganizationChats } from './use-organization-chats' -export { useOrganizationWorkspaces } from './use-organization-workspaces' diff --git a/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx b/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx index 21a31aa0c21..384ecc97b22 100644 --- a/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx +++ b/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx @@ -21,7 +21,7 @@ export function SearchLandingHistory({ onSearch, children, }: SearchLandingHistoryProps) { - const history = useSearchHistory(organizationId) + const history = useSearchHistory(organizationId, userId) const clear = useClearSearchHistory(organizationId, userId) const [selection, setSelection] = useState<'sources' | 'queries' | null>(null) const data = history.isError ? undefined : history.data diff --git a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx index 55ae8cbef3e..55f2d1ff53b 100644 --- a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx @@ -110,7 +110,7 @@ export function AccountActionsMenu({ if (!open && !disconnect.isPending) setSelectedId(null) }} title={`Disconnect ${integrationName}`} - text={`Disconnect ${selected?.displayName ?? integrationName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`} + text={`Disconnect ${selected?.displayName ?? integrationName} from this organization. Workflows using this account will also lose access. You can reconnect later.`} confirm={{ label: 'Disconnect', pendingLabel: 'Disconnecting', diff --git a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx index f886a70644a..cea608ed198 100644 --- a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx @@ -5,11 +5,14 @@ import { ChipConfirmModal, ChipModalError } from '@sim/emcn' import { type SettingsAction, SettingsActionChips } from '@/components/settings/settings-header' import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' +import { useRemovePersonalSearchCredential } from '@/hooks/queries/personal-search-integrations' interface AccountConnectionActionsProps { organizationId: string integrationName: string account?: ViewerSearchSourceAccount + /** A saved credential (Jama, Flow) is removed through Search, not the managed-account route. */ + saved?: boolean onConnect?: () => void connectLabel: 'Connect' | 'Add account' onReconnect: () => void @@ -23,6 +26,7 @@ export function AccountConnectionActions({ organizationId, integrationName, account, + saved = false, onConnect, connectLabel, onReconnect, @@ -30,7 +34,9 @@ export function AccountConnectionActions({ canConnect, connectionPending, }: AccountConnectionActionsProps) { - const disconnect = useDisconnectPersonalOrganizationAccount() + const disconnectManaged = useDisconnectPersonalOrganizationAccount() + const removeSaved = useRemovePersonalSearchCredential() + const disconnect = saved ? removeSaved : disconnectManaged const [confirming, setConfirming] = useState(false) const pending = connectionPending || disconnect.isPending const actions: SettingsAction[] = [] @@ -78,11 +84,12 @@ export function AccountConnectionActions({ pendingLabel: 'Disconnecting', pending: disconnect.isPending, disabled: disconnect.isPending, - onClick: () => - disconnect.mutate( - { credentialId: account.credentialId, organizationId }, - { onSuccess: () => setConfirming(false) } - ), + onClick: () => { + const body = { credentialId: account.credentialId, organizationId } + const onSuccess = () => setConfirming(false) + if (saved) removeSaved.mutate(body, { onSuccess }) + else disconnectManaged.mutate(body, { onSuccess }) + }, }} > {disconnect.error?.message} diff --git a/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx index eb976ef4196..8aecd1836d3 100644 --- a/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx @@ -4,12 +4,13 @@ import { ChipSelect, toast } from '@sim/emcn' import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations' import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers' +import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits' +import { assistantAccountOptions } from '@/lib/credential-groups/member-integrations' import { getCredentialGroupProviderId, getCredentialGroupProviderService, } from '@/lib/credential-groups/providers' import { AccountActionsMenu } from '@/app/o/[organizationId]/integrations/account-actions-menu' -import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -120,7 +121,7 @@ export function AssistantAccountIntegrations({ integrationName={service.name} accounts={accounts} onConnect={ - accounts.length === 0 + accounts.length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT ? () => connect.mutate({ organizationId, optionId: option.id }, { onError }) : undefined } diff --git a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx index b7906e5cfb8..184d2423fa7 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx @@ -1,15 +1,17 @@ 'use client' -import type { ReactNode } from 'react' +import { type ReactNode, useState } from 'react' import { toast } from '@sim/emcn' +import type { MemberIntegration } from '@/lib/credential-groups/member-integrations' +import { liveSearchSavedMemberProvider } from '@/lib/sim-search/live/source-catalog' import { AccountConnectionActions } from '@/app/o/[organizationId]/integrations/account-connection-actions' import { AccountToolPermissionsContent } from '@/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions' -import type { MemberIntegration } from '@/app/o/[organizationId]/integrations/utils' import { CredentialDetailHeading, CredentialDetailLayout, } from '@/app/workspace/[workspaceId]/components/credential-detail' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SavedSearchCredentialModal } from '@/app/workspace/[workspaceId]/integrations/components/saved-search-credential-modal' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { @@ -31,6 +33,13 @@ interface IntegrationDetailProps { children?: ReactNode } +function savedProviderIdFor(provider: string) { + const providerId = liveSearchSavedMemberProvider(provider) + return providerId === 'jama-service-account' || providerId === 'flow-service-account' + ? providerId + : null +} + /** The selected account owns every permission and connection action in this view. */ export function IntegrationDetail({ organizationId, @@ -49,6 +58,12 @@ export function IntegrationDetail({ ? integration.accounts[0] : undefined const target = integration.connectionTarget + const savedProvider = savedProviderIdFor(integration.provider) + const [savedConnection, setSavedConnection] = useState<{ + providerId: 'jama-service-account' | 'flow-service-account' + credentialId?: string + displayName?: string + } | null>(null) return ( connect.mutate({ organizationId, ...target }, { onError }) : undefined + !target + ? undefined + : 'savedProviderId' in target + ? () => savedProvider && setSavedConnection({ providerId: savedProvider }) + : () => connect.mutate({ organizationId, ...target }, { onError }) } onReconnect={() => { - if (selected) reconnect.mutate(selected.credentialId, { onError }) + if (!selected) return + if (savedProvider) + setSavedConnection({ + providerId: savedProvider, + credentialId: selected.credentialId, + displayName: selected.displayName, + }) + else reconnect.mutate(selected.credentialId, { onError }) }} connectionPending={connect.isPending || reconnect.isPending} canConnect={integration.ready} @@ -121,6 +148,14 @@ export function IntegrationDetail({ credentialId={selected.credentialId} /> )} + {savedConnection && ( + setSavedConnection(null)} + onCreated={() => setSavedConnection(null)} + /> + )} ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx index d8fc4bdf7f7..6080b869c08 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { ChipLink } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' +import { memberIntegrationEntries } from '@/lib/credential-groups/member-integrations' import { organizationRoutes } from '@/lib/navigation/paths' import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' import { IntegrationDetail } from '@/app/o/[organizationId]/integrations/integration-detail' @@ -11,10 +12,7 @@ import { integrationViewParsers, serializeIntegrationView, } from '@/app/o/[organizationId]/integrations/search-params' -import { - memberIntegrationEntries, - memberIntegrationStatus, -} from '@/app/o/[organizationId]/integrations/utils' +import { memberIntegrationStatus } from '@/app/o/[organizationId]/integrations/utils' import { GenericSecretSourceIcon, GenericSecretSourceRow, diff --git a/apps/sim/app/o/[organizationId]/integrations/page.tsx b/apps/sim/app/o/[organizationId]/integrations/page.tsx index befc3a35fa5..d4afd11876f 100644 --- a/apps/sim/app/o/[organizationId]/integrations/page.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/page.tsx @@ -25,6 +25,7 @@ interface OrganizationIntegrationsPageProps { slack?: string | string[] connectionMode?: string | string[] optionId?: string | string[] + savedProviderId?: string | string[] provider?: string | string[] connectorType?: string | string[] connectorId?: string | string[] @@ -47,11 +48,13 @@ export default async function OrganizationIntegrationsPage({ selection.credentialId || selection.connectionMode || selection.optionId || + selection.savedProviderId || selection.provider ? searchConnectionTargetSchema.safeParse({ type: 'link', ...(selection.connectionMode ? { connectionMode: selection.connectionMode } : {}), ...(selection.optionId ? { optionId: selection.optionId } : {}), + ...(selection.savedProviderId ? { savedProviderId: selection.savedProviderId } : {}), provider: selection.provider || connector?.providerId, connectorType: selection.connectorType, ...(selection.connectorId ? { connectorId: selection.connectorId } : {}), diff --git a/apps/sim/app/o/[organizationId]/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/integrations/search-params.ts index b1d784d7f7d..20bbc419dec 100644 --- a/apps/sim/app/o/[organizationId]/integrations/search-params.ts +++ b/apps/sim/app/o/[organizationId]/integrations/search-params.ts @@ -16,6 +16,7 @@ export const integrationConnectionParams = { connectorType: parseAsString.withDefault(''), connectionMode: parseAsString.withDefault(''), optionId: parseAsString.withDefault(''), + savedProviderId: parseAsString.withDefault(''), provider: parseAsString.withDefault(''), connectorId: parseAsString.withDefault(''), credentialId: parseAsString.withDefault(''), diff --git a/apps/sim/app/o/[organizationId]/integrations/utils.ts b/apps/sim/app/o/[organizationId]/integrations/utils.ts index e00352082a5..1a5e3cdbe27 100644 --- a/apps/sim/app/o/[organizationId]/integrations/utils.ts +++ b/apps/sim/app/o/[organizationId]/integrations/utils.ts @@ -1,236 +1,4 @@ -import type { ComponentType } from 'react' -import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations' -import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' -import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers' -import { - findCredentialGroupProviderFromProviderId, - getCredentialGroupProviderId, - getCredentialGroupProviderService, -} from '@/lib/credential-groups/providers' -import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes' -import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' -import { - LIVE_SEARCH_SOURCE_TYPES, - liveSearchMcpConnector, - liveSearchMemberAccountProvider, -} from '@/lib/sim-search/live/source-catalog' - -const SEARCH_ACCOUNT_PROVIDERS = new Set([ - 'slack', - ...LIVE_SEARCH_SOURCE_TYPES.flatMap(([source]) => { - const provider = liveSearchMemberAccountProvider(source) - return provider ? [provider] : [] - }), -]) - -export function assistantAccountOptions(data: OrganizationAccountsSettings, search: string) { - const providers = data.assistantProviders.filter( - (provider) => !SEARCH_ACCOUNT_PROVIDERS.has(provider) - ) - const configured = - data.credentialGroup?.options.filter( - (option) => !SEARCH_ACCOUNT_PROVIDERS.has(option.provider) - ) ?? [] - const query = search.trim().toLowerCase() - const visible = configured.filter((option) => - [ - getCredentialGroupProviderService(option.provider).name, - ...(data.viewerAccounts ?? []) - .filter((account) => account.optionId === option.id) - .map((account) => account.displayName), - ] - .join(' ') - .toLowerCase() - .includes(query) - ) - return { providers, configured, visible } -} - -export interface MemberIntegration { - id: string - provider: string - name: string - icon: ComponentType<{ className?: string }> - accounts: MemberIntegrationAccount[] - permissionAccountIds: string[] - reconnectAccountIds: string[] - connectionTarget?: { optionId: string } | { mcpServerId: string } - ready: boolean - managed?: boolean - description?: string -} - -type MemberIntegrationAccount = - | NonNullable[number] - | NonNullable[number] - -/** One member inventory, preserving native grant and managed MCP server identities. */ -export function memberIntegrationEntries( - data: OrganizationAccountsSettings, - policies: SearchIntegrationStatus[] | undefined -): MemberIntegration[] { - const group = data.credentialGroup - const approvals = new Map(policies?.map((policy) => [policy.connectorType, policy])) - const permissionIds = new Set( - (data.viewerAccounts ?? []) - .filter((account) => { - const provider = findCredentialGroupProviderFromProviderId(account.providerId) - return provider !== null && data.assistantProviders.includes(provider) - }) - .map((account) => account.credentialId) - ) - const mcpProviders = new Map( - group?.mcpServers.map((server) => [server.id, server.managedConnectorId]) - ) - const entries: MemberIntegration[] = [] - for (const [provider, meta] of LIVE_SEARCH_SOURCE_TYPES) { - const memberProvider = - provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) - const mcp = liveSearchMcpConnector(provider) - const accounts: MemberIntegrationAccount[] = mcp - ? (data.viewerMcpAccounts ?? []).filter( - (account) => mcpProviders.get(account.mcpServerId) === provider - ) - : (data.viewerAccounts ?? []).filter( - (account) => - findCredentialGroupProviderFromProviderId(account.providerId) === memberProvider - ) - const approval = approvals.get(provider) - const available = - (provider !== 'hubspot' && provider !== 'zoom') || - data.availableMcpConnectors.includes(provider) - const configured = - group?.options.some((option) => option.provider === memberProvider) || - group?.mcpServers.some((server) => server.managedConnectorId === provider) - if ( - !LIVE_SEARCH_SCOPE_FIELDS[provider] || - (!accounts.length && - !configured && - (approval?.available === false || !available || !approval?.approved)) - ) - continue - const options = - group?.options.filter( - (option) => - option.provider === memberProvider && - option.status === 'active' && - option.configurationStatus === 'ready' && - (provider !== 'slack' || - (option.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) - ) ?? [] - const servers = mcp - ? (group?.mcpServers.filter( - (server) => server.managedConnectorId === provider && server.enabled - ) ?? []) - : [] - const option = options.find( - (candidate) => - !accounts.some((account) => 'optionId' in account && account.optionId === candidate.id) - ) - const server = servers.find( - (candidate) => - !accounts.some( - (account) => 'mcpServerId' in account && account.mcpServerId === candidate.id - ) - ) - const ready = - (mcp - ? available - : memberProvider !== null && data.availableProviders.includes(memberProvider)) && - approval?.available !== false && - group?.status === 'active' && - (options.length > 0 || servers.length > 0) && - approval?.approved === true && - available - const description = - provider === 'gitlab' - ? 'Access follows project permissions' - : !policies - ? undefined - : approval?.available === false - ? 'Currently unavailable' - : !approval?.approved - ? 'Disabled by your organization' - : group && group.status !== 'active' - ? 'Connections are paused by your organization' - : !ready - ? 'Not configured' - : approval.policy?.accessMode === 'service_account' - ? 'Selected resources you can access' - : 'All accessible content' - entries.push({ - id: `source:${provider}`, - provider, - name: meta.name, - icon: meta.icon, - accounts, - ready, - managed: provider === 'gitlab', - description, - permissionAccountIds: accounts - .filter((account) => permissionIds.has(account.credentialId)) - .map((account) => account.credentialId), - reconnectAccountIds: ready - ? accounts - .filter((account) => - 'optionId' in account - ? options.some((candidate) => candidate.id === account.optionId) - : servers.some((candidate) => candidate.id === account.mcpServerId) - ) - .map((account) => account.credentialId) - : [], - connectionTarget: server - ? { mcpServerId: server.id } - : option - ? { optionId: option.id } - : undefined, - }) - } - const { providers, configured } = assistantAccountOptions(data, '') - for (const provider of new Set(configured.map((option) => option.provider))) { - const options = configured.filter((option) => option.provider === provider) - const accounts = (data.viewerAccounts ?? []).filter((account) => - options.some((option) => option.id === account.optionId) - ) - const source = assistantSearchSourceForProvider(getCredentialGroupProviderId(provider)) - const policy = source ? approvals.get(source) : undefined - const readyOptions = options.filter( - (option) => - group?.status === 'active' && - option.status === 'active' && - option.configurationStatus === 'ready' && - providers.includes(provider) && - data.availableProviders.includes(provider) && - (!source || (policy?.approved === true && policy.available !== false)) - ) - const target = readyOptions.find( - (option) => !accounts.some((account) => account.optionId === option.id) - ) - const service = getCredentialGroupProviderService(provider) - entries.push({ - id: `app:${provider}`, - provider, - name: service.name, - icon: service.icon, - accounts, - ready: readyOptions.length > 0, - description: - source && !policies - ? undefined - : readyOptions.length - ? undefined - : 'Connections unavailable', - connectionTarget: target ? { optionId: target.id } : undefined, - permissionAccountIds: accounts - .filter((account) => permissionIds.has(account.credentialId)) - .map((account) => account.credentialId), - reconnectAccountIds: accounts - .filter((account) => readyOptions.some((option) => option.id === account.optionId)) - .map((account) => account.credentialId), - }) - } - return entries.sort((left, right) => left.name.localeCompare(right.name)) -} +import type { MemberIntegration } from '@/lib/credential-groups/member-integrations' export function memberIntegrationStatus(entry: MemberIntegration) { if (entry.managed) return 'Organization managed' diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx index c09a9c837ce..5eb06b22474 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { SettingsPanel } from '@/components/settings/settings-panel' import type { SearchIntegrationApproval } from '@/lib/api/contracts/knowledge/search-integrations' +import { assistantAccountOptions } from '@/lib/credential-groups/member-integrations' import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes' import { organizationRoutes } from '@/lib/navigation/paths' import { @@ -21,7 +22,6 @@ import { liveSearchMemberAccountProvider, } from '@/lib/sim-search/live/source-catalog' import { AssistantAccountIntegrations } from '@/app/o/[organizationId]/integrations/assistant-account-integrations' -import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { AddOrganizationSourceModal } from '@/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal' import { diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index edf3d280fcf..29c45fa2dd7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -13,10 +13,10 @@ import { toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' -import type { BatchInvitationResult } from '@/lib/api/contracts/invitations' import { useSession } from '@/lib/auth/client' import { isEnterprise } from '@/lib/billing/plan-helpers' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { buildInviteFailureMessage } from '@/lib/invitations/error-messages' import { quickValidateEmail } from '@/lib/messaging/email/validation' import type { PermissionType } from '@/lib/workspaces/permissions/utils' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -47,40 +47,6 @@ const MEMBERSHIP_HINTS: Partial> = { const EMPTY_WORKSPACE_IDS: string[] = [] -/** Distinct failure reasons listed in full before the remainder is counted instead. */ -const MAX_LISTED_FAILURE_REASONS = 3 - -/** - * Builds the submit error for a batch where some or all invitations failed. - * - * The server rejects per email and every reason names its own address, so the - * reasons are listed rather than collapsed to the first one: inviting several - * people who are all ineligible for the chosen membership is the common - * multi-failure case, and reporting one of them hides who else needs attention. - * Identical reasons are deduplicated, and beyond - * {@link MAX_LISTED_FAILURE_REASONS} the tail is counted rather than listed so a - * large batch cannot bury the modal in near-identical sentences. - */ -export function buildInviteFailureMessage( - failures: BatchInvitationResult['failed'], - attemptedCount: number -): string { - if (failures.length === 1) return failures[0].error - - const headline = - failures.length >= attemptedCount - ? `None of the ${failures.length} invitations could be sent.` - : `${failures.length} of ${attemptedCount} invitations could not be sent.` - - const reasons = [...new Set(failures.map((failure) => failure.error))] - const listed = reasons.slice(0, MAX_LISTED_FAILURE_REASONS) - const remaining = reasons.length - listed.length - - return [headline, ...listed, remaining > 0 ? `And ${remaining} more.` : null] - .filter(Boolean) - .join(' ') -} - interface InviteModalProps { open: boolean onOpenChange: (open: boolean) => void diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx index 74b4ca76313..a9c1d36f774 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -71,8 +71,8 @@ import type { ResourceScope } from '@/lib/core/resource-scope' import { SearchLandingHistory } from '@/app/o/[organizationId]/components/search-landing-history' import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results' -import { searchHistoryKeys } from '@/hooks/queries/search-history' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' +import { searchHistoryKeys } from '@/hooks/queries/utils/search-history-keys' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization/search') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index 0bbe8af07fb..aa84967bfba 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -4,6 +4,6 @@ export { ChatContent } from './chat-content' export { MessageSources } from './message-sources' export { Options } from './options' export { QuestionDisplay } from './question' -export { highlightTerms, SourceCard } from './source-card' +export { SourceCard } from './source-card' export { SourceChip, sourceLabel } from './source-chip' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/index.ts index ad7cccef9e3..269cd5ca45c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/index.ts @@ -1,4 +1 @@ -export { - highlightTerms, - SourceCard, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card' +export { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx index dfe307267f9..03a469f216d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx @@ -46,7 +46,7 @@ const SOURCE_ROW_MARK_CLASSES = cn(chipIconSlotClass, 'mt-0.5') * document matched. Terms are matched as whole words in any script, * case-insensitively, by the same rule the snippet was centred with. */ -export function highlightTerms(text: string, query: string | undefined): ReactNode { +function highlightTerms(text: string, query: string | undefined): ReactNode { const matches = findTermMatches(text, queryTerms(query)) if (matches.length === 0) return text const parts: ReactNode[] = [] 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 5bdf383a8c7..40e964d7c56 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 @@ -2,16 +2,12 @@ import { Chip } from '@sim/emcn' import { Check } from '@sim/emcn/icons' -import { - getCredentialGroupProviderService, - isCredentialGroupProvider, -} from '@/lib/credential-groups/providers' import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' -import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' import { InteractionCard, InteractionCardActionRow, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card' +import { SavedSearchCredentialModal } from '@/app/workspace/[workspaceId]/integrations/components/saved-search-credential-modal' import { BrandIcon } from '@/blocks/brand-icon' import { useSearchIntegrationConnection } from '@/hooks/use-search-integration-connection' @@ -44,7 +40,6 @@ function SearchIntegrationConnectionControl({ divided, onConnected, }: SearchIntegrationConnectionProps) { - const connector = SEARCH_CONNECTORS.find((entry) => entry.type === target.connectorType) const connection = useSearchIntegrationConnection({ organizationId, userId, @@ -52,34 +47,22 @@ function SearchIntegrationConnectionControl({ controlId, onConnected, }) - const service = isCredentialGroupProvider(target.provider) - ? getCredentialGroupProviderService(target.provider) - : undefined - const name = connector?.meta.name ?? service?.name ?? target.provider - const icon = connector?.serviceIcon ?? service?.icon - const action = target.credentialId ? 'Reconnect' : 'Connect' - const connected = connection.connected - const label = connected - ? `Connected ${name}` - : connection.isLoading - ? `Checking ${name} connections` - : connection.pending - ? `Waiting for ${name} connection` - : !connection.available - ? `${name} connection is no longer available` - : `${action} ${name}` + const { icon, label, disabled, connected } = connection const handleConnect = () => void connection.connect() const content = ( <> + {connection.savedConnection && ( + + )} } trailing={connected ? : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 44b3ead0da7..a5f9c92907c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -115,7 +115,7 @@ import { } from '@/lib/mothership/chat/sim-key-redaction' import type { CredentialItemData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' -import { organizationSecretKeys } from '@/hooks/queries/organization-secrets' +import { organizationSecretKeys } from '@/hooks/queries/utils/organization-secret-keys' const mockOrganizationContext = organizationProviderMockFns.mockUseOptionalOrganizationContext const mockSession = authClientMockFns.mockUseSession diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx index 36f775af45b..dcdc3920d4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx @@ -12,6 +12,7 @@ import { SecretInput, } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { isApiClientError } from '@/lib/api/client/errors' import { resourceScopeFields, @@ -25,6 +26,7 @@ import { type ClientCredentialAccountFieldId, partitionClientCredentialFields, } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret' import { withBrandIcon } from '@/blocks/brand-icon' import { useCreateScopedCredential, @@ -43,7 +45,8 @@ const FALLBACK_ERROR_MESSAGE = "We couldn't save this credential. Try again in a function messageForClientCredentialError( err: unknown, descriptor: ClientCredentialAccountDescriptor, - requiredFields: ClientCredentialAccountField[] + requiredFields: ClientCredentialAccountField[], + fallbackMessage = FALLBACK_ERROR_MESSAGE ): string { if (isApiClientError(err) && err.code) { // Names the fields the *selected* auth method needed, so a JWT failure @@ -66,10 +69,10 @@ function messageForClientCredentialError( case 'duplicate_display_name': return 'A credential with that name already exists in this workspace.' default: - return FALLBACK_ERROR_MESSAGE + return fallbackMessage } } - return FALLBACK_ERROR_MESSAGE + return fallbackMessage } type FieldValues = Partial> @@ -92,6 +95,10 @@ interface ClientCredentialAccountModalProps { initialDescription?: string /** Called with the credential id after a successful create or reconnect. */ onCreated?: (credentialId: string) => void + onSubmitCredentials?: ( + fields: ServiceAccountSecretFields & { displayName?: string; description?: string } + ) => Promise + submitPending?: boolean } /** @@ -129,6 +136,8 @@ function ClientCredentialAccountModalForm({ initialDisplayName, initialDescription, onCreated, + onSubmitCredentials, + submitPending, }: ClientCredentialAccountModalProps) { const [values, setValues] = useState({}) const [displayName, setDisplayName] = useState(initialDisplayName ?? '') @@ -167,7 +176,8 @@ function ClientCredentialAccountModalForm({ const missingRequired = mustRestateAuthMethod || required.some((field) => !values[field.id]?.trim()) - const isPending = createCredential.isPending || updateCredential.isPending + const isPending = + createCredential.isPending || updateCredential.isPending || submitPending === true const isDisabled = missingRequired || isPending const actionLabel = credentialId ? 'Reconnect' : 'Add' @@ -195,6 +205,16 @@ function ClientCredentialAccountModalForm({ const value = field.preserveWhitespace ? values[field.id] : values[field.id]?.trim() if (value) secretFields[field.id] = value } + if (onSubmitCredentials) { + const savedId = await onSubmitCredentials({ + ...secretFields, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + onCreated?.(savedId) + onOpenChange(false) + return + } if (credentialId) { await updateCredential.mutateAsync({ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), @@ -217,7 +237,14 @@ function ClientCredentialAccountModalForm({ if (connectedCredentialId) onCreated?.(connectedCredentialId) onOpenChange(false) } catch (err: unknown) { - setError(messageForClientCredentialError(err, descriptor, required)) + setError( + messageForClientCredentialError( + err, + descriptor, + required, + onSubmitCredentials ? getErrorMessage(err, FALLBACK_ERROR_MESSAGE) : undefined + ) + ) logger.error(`Failed to save ${descriptor.serviceLabel} service account credential`, err) } } 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 f79e6d92a8d..b9d825364c3 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 @@ -25,6 +25,7 @@ import { type ClientCredentialAccountProviderId, getClientCredentialAccountDescriptor, } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret' import { getTokenServiceAccountDescriptor, type TokenServiceAccountProviderId, @@ -139,6 +140,10 @@ interface ConnectServiceAccountModalProps { credentialDescription?: string /** Called with the new credential id after a successful create (token-paste providers). */ onCreated?: (credentialId: string) => void + onSubmitCredentials?: ( + fields: ServiceAccountSecretFields & { displayName?: string; description?: string } + ) => Promise + submitPending?: boolean } /** @@ -169,6 +174,8 @@ export function ConnectServiceAccountModal({ credentialDisplayName, credentialDescription, onCreated, + onSubmitCredentials, + submitPending, }: ConnectServiceAccountModalProps) { if (serviceAccountProviderId === ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID) { return ( @@ -189,6 +196,8 @@ export function ConnectServiceAccountModal({ if (clientCredentialDescriptor) { return ( void + onSubmitCredentials?: ( + fields: ServiceAccountSecretFields & { displayName?: string; description?: string } + ) => Promise + submitPending?: boolean } /** @@ -86,9 +92,12 @@ function TokenServiceAccountModalForm({ initialDisplayName, initialDescription, onCreated, + onSubmitCredentials, + submitPending, }: TokenServiceAccountModalProps) { const [apiToken, setApiToken] = useState('') const [domain, setDomain] = useState('') + const [providerWorkspaceId, setProviderWorkspaceId] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') const [description, setDescription] = useState(initialDescription ?? '') const [error, setError] = useState(null) @@ -98,11 +107,17 @@ function TokenServiceAccountModalForm({ const tokenField = descriptor.fields.find((field) => field.id === 'apiToken') const domainField = descriptor.fields.find((field) => field.id === 'domain') + const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId') const trimmedToken = apiToken.trim() const normalizedDomain = normalizeDomainInput(domain) - const isPending = createCredential.isPending || updateCredential.isPending - const isDisabled = !trimmedToken || (Boolean(domainField) && !normalizedDomain) || isPending + const isPending = + createCredential.isPending || updateCredential.isPending || submitPending === true + const isDisabled = + !trimmedToken || + (Boolean(domainField) && !normalizedDomain) || + (Boolean(workspaceField) && !providerWorkspaceId.trim()) || + isPending const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => { if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined @@ -115,8 +130,19 @@ function TokenServiceAccountModalForm({ try { const secretFields = { apiToken: trimmedToken, + ...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}), ...(domainField ? { domain: normalizedDomain } : {}), } + if (onSubmitCredentials) { + const savedId = await onSubmitCredentials({ + ...secretFields, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + onCreated?.(savedId) + onOpenChange(false) + return + } if (credentialId) { await updateCredential.mutateAsync({ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), @@ -140,7 +166,13 @@ function TokenServiceAccountModalForm({ onOpenChange(false) } catch (err: unknown) { const code = isApiClientError(err) ? err.code : undefined - setError(getTokenServiceAccountErrorMessage(descriptor, code)) + setError( + getTokenServiceAccountErrorMessage( + descriptor, + code, + onSubmitCredentials ? getErrorMessage(err, 'Could not save this connection.') : undefined + ) + ) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) } } @@ -179,6 +211,17 @@ function TokenServiceAccountModalForm({ )} + {workspaceField && ( + + )} {domainField && ( void + onCreated?: (credentialId: string) => void +} + +/** Search uses the same credential fields as workflows, with a member-owned save operation. */ +export function SavedSearchCredentialModal({ + organizationId, + connection, + onClose, + onCreated, +}: SavedSearchCredentialModalProps) { + const save = useSavePersonalSearchCredential() + const service = getServiceConfigByProviderId(connection.providerId) + if (!service) return null + return ( + { + if (!open) onClose() + }} + organizationId={organizationId} + serviceAccountProviderId={connection.providerId} + serviceName={service.name} + serviceIcon={service.icon} + credentialId={connection.credentialId} + credentialDisplayName={connection.displayName} + submitPending={save.isPending} + onCreated={onCreated} + onSubmitCredentials={async (fields) => + ( + await save.mutateAsync({ + ...fields, + organizationId, + providerId: connection.providerId, + credentialId: connection.credentialId, + }) + ).credentialId + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index f9cabc91f93..0105c89b89d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -45,6 +45,7 @@ import { } from '@/lib/billing/subscriptions/utils' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' +import { AwsMarketplaceSubscriptionSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/aws-marketplace-subscription-section/aws-marketplace-subscription-section' import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section' import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' @@ -52,6 +53,7 @@ import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useAwsMarketplaceBilling } from '@/hooks/queries/aws-marketplace' import { useBillingUsageNotifications, useUpdateGeneralSetting, @@ -133,6 +135,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps enabled: !isOrganizationScope, }) const billingOrganizationId = isOrganizationScope ? (organizationId ?? null) : null + const marketplaceBilling = useAwsMarketplaceBilling(billingOrganizationId) + const { billedThroughMarketplace } = marketplaceBilling const { data: organizationBillingData, @@ -452,8 +456,11 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps ? `Organization ${planName} plan ended` : `Organization ${planName} plan` : `Personal ${planName} plan` - const priceText = - organizationSubscriptionState === 'free' + const priceText = billedThroughMarketplace + ? subscription.isFree + ? 'AWS Marketplace setup in progress' + : 'Billed through AWS Marketplace' + : organizationSubscriptionState === 'free' ? 'No active organization subscription' : organizationSubscriptionState === 'lapsed' ? 'Choose a new plan for this organization' @@ -514,6 +521,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps {!subscription.isEnterprise && + marketplaceBilling.isResolved && + !billedThroughMarketplace && (canExplorePlans && upgradeHref ? ( )} + + {isOrganizationScope && ( + + )} + {!subscription.isFree && invoices.length > 0 && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/aws-marketplace-subscription-section/aws-marketplace-subscription-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/aws-marketplace-subscription-section/aws-marketplace-subscription-section.tsx new file mode 100644 index 00000000000..f4a2250c043 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/aws-marketplace-subscription-section/aws-marketplace-subscription-section.tsx @@ -0,0 +1,76 @@ +'use client' + +import { Badge } from '@sim/emcn' +import { formatDate } from '@sim/utils/formatting' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useAwsMarketplaceLicenses } from '@/hooks/queries/aws-marketplace' + +const STATUS_BADGES = { + active: { label: 'Active', variant: 'green' }, + pending: { label: 'Confirming with AWS', variant: 'amber' }, + inactive: { label: 'Inactive', variant: 'gray' }, +} as const + +interface AwsMarketplaceSubscriptionSectionProps { + /** Null reads the licenses registered to the viewer's own account. */ + organizationId: string | null + label?: string +} + +/** Read-only: AWS requires buyers to see their subscription status in the product. */ +export function AwsMarketplaceSubscriptionSection({ + organizationId, + label = 'AWS Marketplace subscription', +}: AwsMarketplaceSubscriptionSectionProps) { + const { data: licenses, error, isFetching, refetch } = useAwsMarketplaceLicenses(organizationId) + if (error && !licenses) { + return ( + + refetch()} + /> + + ) + } + if (!licenses?.length) return null + + return ( + +
+ {licenses.map((license) => { + const badge = STATUS_BADGES[license.status] + const rows = [ + { label: 'Plan', value: license.dimension ?? 'Not specified' }, + { label: 'Quantity', value: license.quantity?.toLocaleString() ?? 'Not specified' }, + { + label: 'Expires', + value: license.expiresAt ? formatDate(new Date(license.expiresAt)) : 'No expiry', + }, + { label: 'Last verified', value: formatDate(new Date(license.lastVerifiedAt)) }, + ] + return ( +
+
+ Status + + {badge.label} + +
+ {rows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+ ) + })} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index bb7299df95d..c3ea986599f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -47,6 +47,7 @@ import { ZeroBounceIcon, } from '@/components/icons' import { NebiusIcon } from '@/components/icons/nebius' +import { ThinkingMachinesIcon } from '@/components/icons/thinking-machines' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { type BYOKProviderId, MAX_BYOK_KEYS_PER_PROVIDER } from '@/lib/api/contracts/byok-keys' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' @@ -182,6 +183,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls', placeholder: 'Enter your Kie API key', }, + { + id: 'thinkingmachines', + name: 'Thinking Machines', + icon: ThinkingMachinesIcon, + description: 'LLM calls', + placeholder: 'Enter your Thinking Machines Tinker API key', + }, { id: 'falai', name: 'Fal.ai', @@ -383,6 +391,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'nebius', 'ollama-cloud', 'kie', + 'thinkingmachines', 'falai', ], }, diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts index 5f443636eb3..d193aa30dac 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts @@ -209,7 +209,7 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [ values: [false, false, false, true], }, { - label: 'Self Hosting', + label: 'Self-Hosted Enterprise Features', values: [false, false, false, true], }, { diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts index 15152b4a57b..6b44cbff1b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts @@ -9,6 +9,7 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade' import { CREDIT_TIERS } from '@/lib/billing/constants' import { getPlanTierCredits, isEnterprise, isFree, isPro, isTeam } from '@/lib/billing/plan-helpers' +import { useAwsMarketplaceBilling } from '@/hooks/queries/aws-marketplace' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys' import { workspaceHostKeys } from '@/hooks/queries/workspace-host' @@ -38,6 +39,11 @@ export interface UpgradeState { status: string } showUpgradePlans: boolean + billedThroughMarketplace: boolean + /** How this workspace is billed could not be read, so plans stay hidden until a retry succeeds. */ + billingLookupFailed: boolean + isRetryingBillingLookup: boolean + retryBillingLookup: () => void proTier: { credits: number; dollars: number; name: string } maxTier: { credits: number; dollars: number; name: string } isOnPro: boolean @@ -65,6 +71,7 @@ export function useUpgradeState({ const { handleUpgrade } = useSubscriptionUpgrade() const queryClient = useQueryClient() const { ownerBilling } = hostContext + const marketplace = useAwsMarketplaceBilling(hostContext.hostOrganizationId ?? null) const [isAnnual, setIsAnnual] = useState( !ownerBilling.isPaid || ownerBilling.billingInterval === 'year' ) @@ -197,7 +204,12 @@ export function useUpgradeState({ isAnnual, setIsAnnual, subscription, - showUpgradePlans: !subscription.isEnterprise, + showUpgradePlans: + !subscription.isEnterprise && marketplace.isResolved && !marketplace.billedThroughMarketplace, + billedThroughMarketplace: marketplace.billedThroughMarketplace, + billingLookupFailed: !marketplace.isResolved && marketplace.isError, + isRetryingBillingLookup: marketplace.isRetrying, + retryBillingLookup: marketplace.retry, proTier: PRO_TIER, maxTier: MAX_TIER, isOnPro, diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 1bad0a5626b..62c3a20e58f 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -85,10 +85,15 @@ export function Upgrade({ workspaceId }: UpgradeProps) { return null } - if (!canManageBilling) { - const description = hostContext.hostOrganizationId - ? `Plans for ${hostContext.workspace.name} are managed by its organization administrators. Contact an organization admin to change this workspace’s plan.` - : `Only the owner of ${hostContext.workspace.name} can change this workspace’s plan.` + if (!canManageBilling || state.billedThroughMarketplace || state.billingLookupFailed) { + const description = !canManageBilling + ? hostContext.hostOrganizationId + ? `Plans for ${hostContext.workspace.name} are managed by its organization administrators. Contact an organization admin to change this workspace’s plan.` + : `Only the owner of ${hostContext.workspace.name} can change this workspace’s plan.` + : state.billedThroughMarketplace + ? 'Your subscription is billed through AWS Marketplace, so plans can’t be changed here.' + : 'We couldn’t confirm how this workspace is billed.' + const canRetryLookup = canManageBilling && state.billingLookupFailed return (
@@ -101,6 +106,15 @@ export function Upgrade({ workspaceId }: UpgradeProps) {

Workspace plans unavailable

{description}

+ {canRetryLookup && ( + + {state.isRetryingBillingLookup ? 'Retrying…' : 'Try again'} + + )}
diff --git a/apps/sim/app/workspace/page.test.tsx b/apps/sim/app/workspace/page.test.tsx new file mode 100644 index 00000000000..4ad27e4954e --- /dev/null +++ b/apps/sim/app/workspace/page.test.tsx @@ -0,0 +1,78 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock' +import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseWorkspacesWithMetadata } = vi.hoisted(() => ({ + mockUseWorkspacesWithMetadata: vi.fn(), +})) + +vi.mock('next/navigation', () => nextNavigationMock) +vi.mock('@/lib/auth/client', () => authClientMock) +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesWithMetadata: mockUseWorkspacesWithMetadata, +})) + +import WorkspacePage from '@/app/workspace/page' + +const mockReplace = nextNavigationMockFns.router.replace + +describe('WorkspacePage for a viewer with nothing to pick', () => { + let container: HTMLDivElement + let root: Root + + function visit(search: string, list: { forwardOrganizationId?: string }) { + window.history.pushState({}, '', `/workspace${search}`) + mockUseWorkspacesWithMetadata.mockReturnValue({ + data: { + workspaces: [], + lastActiveWorkspaceId: null, + pinnedWorkspaceIds: [], + creationPolicy: { canCreate: false, workspaceMode: 'organization' }, + ...list, + }, + isLoading: false, + error: null, + }) + act(() => root.render()) + } + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mockReplace.mockClear() + authClientMockFns.mockUseSession.mockReturnValue({ + data: { user: { id: 'viewer' } }, + isPending: false, + error: null, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('forwards to the organization without showing the blocked card', () => { + visit('', { forwardOrganizationId: 'org-1' }) + expect(mockReplace).toHaveBeenCalledExactlyOnceWith('/o/org-1/home') + expect(container.querySelector('h1')).toBeNull() + }) + + it('carries a settings request to the organization settings', () => { + visit('?redirect=settings', { forwardOrganizationId: 'org-1' }) + expect(mockReplace).toHaveBeenCalledExactlyOnceWith('/o/org-1/settings/general') + }) + + it('stays on the blocked card when no organization can serve them', () => { + visit('', {}) + expect(mockReplace).not.toHaveBeenCalled() + expect(container.querySelector('h1')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 5403128c1d2..2013b453fcb 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -16,6 +16,7 @@ import { isUpgradeReason, UPGRADE_REASON_PARAM, } from '@/lib/billing/upgrade-reasons' +import { organizationRoutes } from '@/lib/navigation/paths' import { ApplicationLoading } from '@/app/_shell/application-loading' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' @@ -133,9 +134,17 @@ export default function WorkspacePage() { ? `/workspace/${id}/settings/general` : `/workspace/${id}` - const { workspaces, lastActiveWorkspaceId, creationPolicy } = data + const { workspaces, lastActiveWorkspaceId, creationPolicy, forwardOrganizationId } = data if (workspaces.length === 0) { + if (forwardOrganizationId) { + hasRedirectedRef.current = true + const routes = organizationRoutes(forwardOrganizationId) + router.replace( + redirectTarget === 'settings' ? routes.settingsSection('general') : routes.home + ) + return + } /** * Blocked state is derived in render and deliberately does NOT set * hasRedirectedRef: a later refetch that shows granted access resumes @@ -174,6 +183,7 @@ export default function WorkspacePage() { isAuthenticated && data && data.workspaces.length === 0 && + !data.forwardOrganizationId && data.creationPolicy && !data.creationPolicy.canCreate ? data.creationPolicy diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts index 15820a1d6b7..a69f018bc3b 100644 --- a/apps/sim/background/async-preprocessing-correlation.test.ts +++ b/apps/sim/background/async-preprocessing-correlation.test.ts @@ -484,8 +484,12 @@ describe('async preprocessing correlation threading', () => { }) expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled() expect(workflowExecutionLogger.info).toHaveBeenCalledWith( - '[request-deleted] Workflow execution ended: its workspace was deleted', - { workflowId: 'workflow-1', executionId: 'execution-deleted-start' } + 'Workflow execution ended: its workspace was deleted', + { + requestId: 'request-deleted', + workflowId: 'workflow-1', + executionId: 'execution-deleted-start', + } ) }) @@ -627,10 +631,13 @@ describe('async preprocessing correlation threading', () => { }) ) expect(loggingSessionMockFns.mockProjectDiagnosticError).toHaveBeenCalledWith(rawError) - expect(workflowExecutionLogger.error).toHaveBeenCalledWith( - '[request-fault] Workflow execution failed: workflow-1', - { executionId: 'execution-fault', error: projectedError, failureKind: 'internal' } - ) + expect(workflowExecutionLogger.error).toHaveBeenCalledWith('Workflow execution failed', { + requestId: 'request-fault', + workflowId: 'workflow-1', + executionId: 'execution-fault', + error: projectedError, + failureKind: 'internal', + }) const loggerPayload = JSON.stringify(workflowExecutionLogger.error.mock.calls) expect(loggerPayload).not.toContain(secret) expect(loggerPayload).not.toContain('__var_') diff --git a/apps/sim/background/cleanup-file-versions.ts b/apps/sim/background/cleanup-file-versions.ts index 593b3ac42d6..de794024400 100644 --- a/apps/sim/background/cleanup-file-versions.ts +++ b/apps/sim/background/cleanup-file-versions.ts @@ -21,7 +21,7 @@ export async function runCleanupFileVersions(payload: CleanupJobPayload): Promis MAX_VERSIONS_PER_RUN ) const elapsed = ((Date.now() - startTime) / 1000).toFixed(2) - logger.info(`[${payload.label}] File version cleanup: ${deleted} released in ${elapsed}s`) + logger.info(`File version cleanup: ${deleted} released in ${elapsed}s`, { label: payload.label }) } export const cleanupFileVersionsTask = task({ diff --git a/apps/sim/background/cleanup-logs.ts b/apps/sim/background/cleanup-logs.ts index c26b2f2a76d..ce5de57c469 100644 --- a/apps/sim/background/cleanup-logs.ts +++ b/apps/sim/background/cleanup-logs.ts @@ -199,7 +199,8 @@ async function cleanupLargeExecutionValues( } logger.info( - `[${label}/execution_large_values] Complete: ${stats.largeValuesDeleted}/${stats.largeValuesTotal} deleted, ${stats.largeValuesDeleteFailed} failed` + `Complete: ${stats.largeValuesDeleted}/${stats.largeValuesTotal} deleted, ${stats.largeValuesDeleteFailed} failed`, + { label: `${label}/execution_large_values` } ) return stats @@ -356,7 +357,8 @@ async function cleanupLegacyLargeExecutionValues( } logger.info( - `[${label}/legacy_execution_large_values] Complete: ${stats.largeValuesDeleted}/${stats.largeValuesTotal} deleted, ${stats.largeValuesDeleteFailed} failed` + `Complete: ${stats.largeValuesDeleted}/${stats.largeValuesTotal} deleted, ${stats.largeValuesDeleteFailed} failed`, + { label: `${label}/legacy_execution_large_values` } ) return stats @@ -378,11 +380,15 @@ async function cleanupLargeValueMetadata( dbClient: cleanupDb, }) logger.info( - `[${label}/execution_large_value_metadata] Pruned ${result.referencesDeleted} stale references, ${result.dependenciesDeleted} dependencies, ${result.tombstonesDeleted} tombstones` + `Pruned ${result.referencesDeleted} stale references, ${result.dependenciesDeleted} dependencies, ${result.tombstonesDeleted} tombstones`, + { label: `${label}/execution_large_value_metadata` } ) } catch (error) { if (budgets) throw error - logger.error(`[${label}/execution_large_value_metadata] Failed to prune metadata`, { error }) + logger.error('Failed to prune metadata', { + label: `${label}/execution_large_value_metadata`, + error, + }) } } @@ -467,7 +473,7 @@ export async function runCleanupLogs( const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000) if (workspaceIds.length === 0) { - logger.info(`[${label}] No workspaces to process`) + logger.info('No workspaces to process', { label }) if (runGlobalHousekeeping && plan === 'free') { await cleanupFreePlanOrphanedSnapshots(retentionHours, budgets?.orphanSnapshots) } @@ -475,7 +481,8 @@ export async function runCleanupLogs( } logger.info( - `[${label}] Cleaning ${workspaceIds.length} workspaces, cutoff: ${retentionDate.toISOString()}` + `Cleaning ${workspaceIds.length} workspaces, cutoff: ${retentionDate.toISOString()}`, + { label } ) const workflowResults = await cleanupWorkflowExecutionLogs( @@ -485,7 +492,8 @@ export async function runCleanupLogs( budgets?.workflowLogs ) logger.info( - `[${label}] workflow_execution_logs files: ${workflowResults.filesDeleted}/${workflowResults.filesTotal} deleted, ${workflowResults.filesDeleteFailed} failed` + `workflow_execution_logs files: ${workflowResults.filesDeleted}/${workflowResults.filesTotal} deleted, ${workflowResults.filesDeleteFailed} failed`, + { label } ) const largeValueResults = await cleanupLargeExecutionValues( workspaceIds, @@ -494,7 +502,8 @@ export async function runCleanupLogs( budgets?.largeValues ) logger.info( - `[${label}] execution_large_values: ${largeValueResults.largeValuesDeleted}/${largeValueResults.largeValuesTotal} deleted, ${largeValueResults.largeValuesDeleteFailed} failed` + `execution_large_values: ${largeValueResults.largeValuesDeleted}/${largeValueResults.largeValuesTotal} deleted, ${largeValueResults.largeValuesDeleteFailed} failed`, + { label } ) const legacyLargeValueResults = await cleanupLegacyLargeExecutionValues( workspaceIds, @@ -503,7 +512,8 @@ export async function runCleanupLogs( budgets?.legacyLargeValues ) logger.info( - `[${label}] legacy_execution_large_values: ${legacyLargeValueResults.largeValuesDeleted}/${legacyLargeValueResults.largeValuesTotal} deleted, ${legacyLargeValueResults.largeValuesDeleteFailed} failed` + `legacy_execution_large_values: ${legacyLargeValueResults.largeValuesDeleted}/${legacyLargeValueResults.largeValuesTotal} deleted, ${legacyLargeValueResults.largeValuesDeleteFailed} failed`, + { label } ) await cleanupLargeValueMetadata(workspaceIds, label, budgets) @@ -523,7 +533,7 @@ export async function runCleanupLogs( } const timeElapsed = (Date.now() - startTime) / 1000 - logger.info(`[${label}] Job completed in ${timeElapsed.toFixed(2)}s`) + logger.info(`Job completed in ${timeElapsed.toFixed(2)}s`, { label }) } export const cleanupLogsTask = task({ diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 49e82024b83..4ac9556de5b 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -168,7 +168,8 @@ async function reRootOne( await preferred() return } catch (error) { - logger.warn(`[${label}] Re-rooting ${subject} under its deduplicated name failed; retrying`, { + logger.warn(`Re-rooting ${subject} under its deduplicated name failed; retrying`, { + label, error, }) } @@ -176,7 +177,7 @@ async function reRootOne( try { await withUniqueName() } catch (error) { - logger.error(`[${label}] Could not re-root ${subject}; its folder delete may stall`, { error }) + logger.error(`Could not re-root ${subject}; its folder delete may stall`, { label, error }) } } @@ -195,7 +196,7 @@ async function reRootActiveFolderChildren( try { await reRootActiveFolderChildrenUnguarded(folderIds, retentionDate, label) } catch (error) { - logger.error(`[${label}] Re-rooting children of purged folders failed`, { error }) + logger.error('Re-rooting children of purged folders failed', { label, error }) } } @@ -300,7 +301,7 @@ async function reRootActiveFolderChildrenUnguarded( } if (workflows.length > 0) { - logger.warn(`[${label}] Re-rooted ${workflows.length} workflow(s) out of folders being purged`) + logger.warn(`Re-rooted ${workflows.length} workflow(s) out of folders being purged`, { label }) } } @@ -408,7 +409,7 @@ async function cleanupOrphanedKnowledgeBaseBindings( const failedKeys = new Set(result.failed.map(({ key }) => key)) deletableKeys = keys.filter((key) => !failedKeys.has(key)) for (const { key, error } of result.failed) { - logger.error(`[${label}] Failed to delete orphan KB object ${key}:`, { error }) + logger.error(`Failed to delete orphan KB object ${key}:`, { label, error }) } } @@ -419,7 +420,7 @@ async function cleanupOrphanedKnowledgeBaseBindings( deletedThisBatch++ } catch (error) { stats.failed++ - logger.error(`[${label}] Failed to delete orphan KB binding ${key}:`, { error }) + logger.error(`Failed to delete orphan KB binding ${key}:`, { label, error }) } } stats.deleted += deletedThisBatch @@ -431,7 +432,8 @@ async function cleanupOrphanedKnowledgeBaseBindings( } logger.info( - `[${label}/kb_orphan_bindings] Complete: ${stats.deleted}/${stats.total} bindings cleaned, ${stats.failed} failed` + `Complete: ${stats.deleted}/${stats.total} bindings cleaned, ${stats.failed} failed`, + { label: `${label}/kb_orphan_bindings` } ) return stats } @@ -445,13 +447,14 @@ export async function runCleanupSoftDeletes( const scope = resolveCleanupOwnerScope(payload) const fileOwners = fileRetentionOwners(payload) if (scope.ids.length === 0 && fileOwners.length === 0) { - logger.info(`[${label}] No resource owners to process`) + logger.info('No resource owners to process', { label }) return } const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000) logger.info( - `[${label}] Processing ${scope.ids.length} ${scope.kind} owners, cutoff: ${retentionDate.toISOString()}` + `Processing ${scope.ids.length} ${scope.kind} owners, cutoff: ${retentionDate.toISOString()}`, + { label } ) // Select workflows + files + soft-deleted chats once. These sets drive BOTH @@ -544,7 +547,7 @@ export async function runCleanupSoftDeletes( totalDeleted += deleted.length } catch (error) { if (budgets) throw error - logger.error(`[${label}/workflow] Archived workflow delete failed`, { error }) + logger.error('Archived workflow delete failed', { label: `${label}/workflow`, error }) } } @@ -569,7 +572,7 @@ export async function runCleanupSoftDeletes( totalDeleted += deleted.length } catch (error) { if (budgets) throw error - logger.error(`[${label}/copilotChats] Soft-deleted chat delete failed`, { error }) + logger.error('Soft-deleted chat delete failed', { label: `${label}/copilotChats`, error }) } } @@ -610,7 +613,8 @@ export async function runCleanupSoftDeletes( ) logger.info( - `[${label}] Complete: ${totalDeleted} rows deleted, ${fileCleanup.filesDeleted} files cleaned, ${orphanBindingStats.deleted} orphan KB bindings cleaned` + `Complete: ${totalDeleted} rows deleted, ${fileCleanup.filesDeleted} files cleaned, ${orphanBindingStats.deleted} orphan KB bindings cleaned`, + { label } ) // Clean up copilot backend + chat storage files after DB rows are gone @@ -619,7 +623,7 @@ export async function runCleanupSoftDeletes( } const timeElapsed = (Date.now() - startTime) / 1000 - logger.info(`[${label}] Job completed in ${timeElapsed.toFixed(2)}s`) + logger.info(`Job completed in ${timeElapsed.toFixed(2)}s`, { label }) } export const cleanupSoftDeletesTask = task({ diff --git a/apps/sim/background/cleanup-tasks.ts b/apps/sim/background/cleanup-tasks.ts index b77ca2344ba..bd07b751d70 100644 --- a/apps/sim/background/cleanup-tasks.ts +++ b/apps/sim/background/cleanup-tasks.ts @@ -79,13 +79,14 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise const scope = resolveCleanupOwnerScope(payload) if (scope.ids.length === 0) { - logger.info(`[${label}] No resource owners to process`) + logger.info('No resource owners to process', { label }) return } const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000) logger.info( - `[${label}] Processing ${scope.ids.length} ${scope.kind} owners, cutoff: ${retentionDate.toISOString()}` + `Processing ${scope.ids.length} ${scope.kind} owners, cutoff: ${retentionDate.toISOString()}`, + { label } ) const doomedChats = await selectRowsByIdChunks(scope.ids, (chunkIds, chunkLimit) => @@ -109,7 +110,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise /** Delete run children before their parent runs. Organization chats have no workspace runs. */ const runChildResults = await cleanupRunChildren(workspaceIds, retentionDate, label) for (const r of runChildResults) { - if (r.deleted > 0) logger.info(`[${r.table}] ${r.deleted} deleted`) + if (r.deleted > 0) logger.info(`${r.deleted} deleted`, { table: r.table }) } const runsResult = @@ -146,7 +147,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise chatsResult.deleted += deleted.length } catch (error) { chatsResult.failed += batch.length - logger.error(`[${label}/copilotChats] Chat retention delete failed`, { error }) + logger.error('Chat retention delete failed', { label: `${label}/copilotChats`, error }) } } @@ -169,7 +170,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise chatsResult.deleted + inboxResult.deleted - logger.info(`[${label}] Complete: ${totalDeleted} total rows deleted`) + logger.info(`Complete: ${totalDeleted} total rows deleted`, { label }) await chatCleanup.execute() diff --git a/apps/sim/background/knowledge-connector-directory-sync.ts b/apps/sim/background/knowledge-connector-directory-sync.ts index 98fb3364af5..2cd9b514a0e 100644 --- a/apps/sim/background/knowledge-connector-directory-sync.ts +++ b/apps/sim/background/knowledge-connector-directory-sync.ts @@ -14,15 +14,15 @@ const logger = createLogger('TriggerKnowledgeConnectorDirectorySync') export async function executeDirectorySyncJob(payload: unknown) { const { connectorId, requestId } = assertDirectorySyncPayload(payload) - logger.info(`[${requestId}] Starting directory refresh: ${connectorId}`) + logger.info(`Starting directory refresh: ${connectorId}`, { requestId }) try { const outcome = await refreshConnectorDirectory(connectorId, requestId) - logger.info(`[${requestId}] Directory refresh finished`, { connectorId, outcome }) + logger.info('Directory refresh finished', { requestId, connectorId, outcome }) return { outcome } } catch (error) { const diagnostic = getConnectorFailureDiagnostic(error) if (!diagnostic) throw error - logger.error(`[${requestId}] Directory refresh failed`, { connectorId, diagnostic }) + logger.error('Directory refresh failed', { requestId, connectorId, diagnostic }) throw new Error(diagnostic.message) } } diff --git a/apps/sim/background/knowledge-connector-member-sync.ts b/apps/sim/background/knowledge-connector-member-sync.ts index 08b1642783f..1057e92ea51 100644 --- a/apps/sim/background/knowledge-connector-member-sync.ts +++ b/apps/sim/background/knowledge-connector-member-sync.ts @@ -43,7 +43,7 @@ export async function executeMemberSyncJob(payload: unknown) { const { connectorId, requestId, billingAttribution, dispatchToken, forceContentRefresh } = assertMemberSyncPayload(payload) - logger.info(`[${requestId}] Starting member sync: ${connectorId}`) + logger.info(`Starting member sync: ${connectorId}`, { requestId }) try { const result = await executeMemberSync(connectorId, { @@ -53,7 +53,8 @@ export async function executeMemberSyncJob(payload: unknown) { }) const outcome = classifyMemberSyncResult(result) - logger.info(`[${requestId}] Member sync completed`, { + logger.info('Member sync completed', { + requestId, connectorId, outcome, deferred: result.deferred, @@ -86,7 +87,7 @@ export async function executeMemberSyncJob(payload: unknown) { return { success: outcome === 'completed', outcome, connectorId, ...result } } catch (error) { - logger.error(`[${requestId}] Member sync failed: ${connectorId}`, error) + logger.error(`Member sync failed: ${connectorId}`, error, { requestId }) throw error } } diff --git a/apps/sim/background/knowledge-connector-sync.test.ts b/apps/sim/background/knowledge-connector-sync.test.ts index 230edb4535e..2a65a63bb51 100644 --- a/apps/sim/background/knowledge-connector-sync.test.ts +++ b/apps/sim/background/knowledge-connector-sync.test.ts @@ -76,7 +76,8 @@ describe('knowledge connector sync worker', () => { processingDispatch: { failed: 1 }, }) expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('1 source failures, 1 dispatch failures') + expect.stringContaining('1 source failures, 1 dispatch failures'), + { requestId: 'request-1' } ) }) diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts index 6df8fc4b100..57939b75ce5 100644 --- a/apps/sim/background/knowledge-connector-sync.ts +++ b/apps/sim/background/knowledge-connector-sync.ts @@ -38,7 +38,7 @@ export async function executeConnectorSyncJob(payload: unknown) { dispatchToken, } = assertConnectorSyncPayload(payload) - logger.info(`[${requestId}] Starting connector sync: ${connectorId}`) + logger.info(`Starting connector sync: ${connectorId}`, { requestId }) try { const result = await executeSync(connectorId, { @@ -50,7 +50,8 @@ export async function executeConnectorSyncJob(payload: unknown) { }) const outcome = classifyConnectorSyncResult(result) - logger.info(`[${requestId}] Connector sync completed`, { + logger.info('Connector sync completed', { + requestId, connectorId, outcome, deferred: result.deferred, @@ -78,7 +79,8 @@ export async function executeConnectorSyncJob(payload: unknown) { } if (outcome === 'partial' && (result.docsFailed > 0 || result.processingDispatch.failed > 0)) { logger.warn( - `[${requestId}] Connector sync partially failed for ${connectorId}: ${result.docsFailed} source failures, ${result.processingDispatch.failed} dispatch failures` + `Connector sync partially failed for ${connectorId}: ${result.docsFailed} source failures, ${result.processingDispatch.failed} dispatch failures`, + { requestId } ) } @@ -89,7 +91,7 @@ export async function executeConnectorSyncJob(payload: unknown) { ...result, } } catch (error) { - logger.error(`[${requestId}] Connector sync failed: ${connectorId}`, error) + logger.error(`Connector sync failed: ${connectorId}`, error, { requestId }) throw error } } diff --git a/apps/sim/background/knowledge-processing.ts b/apps/sim/background/knowledge-processing.ts index 1d09c63fa65..a35582316c5 100644 --- a/apps/sim/background/knowledge-processing.ts +++ b/apps/sim/background/knowledge-processing.ts @@ -103,7 +103,7 @@ export async function runDocumentProcessing( payload.providerRetryCount === undefined && payload.processingSliceCount === undefined - logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`) + logger.info(`Starting Trigger.dev processing for document: ${docData.filename}`, { requestId }) /** Set from the service's callback, so control-flow narrowing cannot see it change. */ let databaseRetryAt = null as Date | null @@ -148,7 +148,7 @@ export async function runDocumentProcessing( } ) - logger.info(`[${requestId}] Document processing finished`, { documentId, ...result }) + logger.info('Document processing finished', { requestId, documentId, ...result }) return { success: result.outcome === 'indexed', @@ -160,7 +160,8 @@ export async function runDocumentProcessing( } catch (error) { if (databaseRetryAt) { const diagnostic = getConnectorFailureDiagnostic(error) - logger.warn(`[${requestId}] Document processing will retry after a database failure`, { + logger.warn('Document processing will retry after a database failure', { + requestId, documentId, diagnostic, attempt: attemptNumber, @@ -178,7 +179,8 @@ export async function runDocumentProcessing( error instanceof ProviderCapacityContinuationExhaustedError ? 'provider_exhausted' : 'provider_deferred' - logger.warn(`[${requestId}] Document processing is waiting for provider recovery`, { + logger.warn('Document processing is waiting for provider recovery', { + requestId, documentId, providerRetryCount: payload.providerRetryCount ?? 0, reason: providerDeferral?.reason, @@ -197,7 +199,8 @@ export async function runDocumentProcessing( } } if (isUsageLimitDocumentProcessingError(error)) { - logger.warn(`[${requestId}] Document processing is blocked by the current usage limit`, { + logger.warn('Document processing is blocked by the current usage limit', { + requestId, filename: docData.filename, }) return { @@ -211,7 +214,8 @@ export async function runDocumentProcessing( } if (isEmbeddingQuotaExhaustion(error)) { const outcome = canScheduleQuotaContinuation ? 'quota_deferred' : 'quota_exhausted' - logger.warn(`[${requestId}] Embedding quota is exhausted`, { + logger.warn('Embedding quota is exhausted', { + requestId, filename: docData.filename, quotaRetryCount: payload.quotaRetryCount ?? 0, continuationLimit: MAX_QUOTA_CONTINUATION_ATTEMPTS, @@ -227,7 +231,8 @@ export async function runDocumentProcessing( } } if (isBYOKEmbeddingCredentialRejection(error)) { - logger.warn(`[${requestId}] Customer-managed embedding credentials were rejected`, { + logger.warn('Customer-managed embedding credentials were rejected', { + requestId, filename: docData.filename, status: error.status, }) @@ -243,7 +248,8 @@ export async function runDocumentProcessing( } const ocrRejection = getOcrRequestRejection(error) if (ocrRejection) { - logger.warn(`[${requestId}] OCR request requires remediation before retrying`, { + logger.warn('OCR request requires remediation before retrying', { + requestId, status: ocrRejection.status, code: ocrRejection.code, }) @@ -258,7 +264,8 @@ export async function runDocumentProcessing( } } if (isPermanentDocumentProcessingError(error)) { - logger.warn(`[${requestId}] Document cannot be processed without changing its content`, { + logger.warn('Document cannot be processed without changing its content', { + requestId, code: error.code, filename: docData.filename, }) @@ -273,10 +280,9 @@ export async function runDocumentProcessing( } } const diagnostic = getConnectorFailureDiagnostic(error) - logger.error( - `[${requestId}] Failed to process document: ${docData.filename}`, - diagnostic ?? error - ) + logger.error(`Failed to process document: ${docData.filename}`, diagnostic ?? error, { + requestId, + }) /** Trigger records the thrown message and stack, never `cause`; Drizzle's message carries SQL. */ if (diagnostic?.category === 'database') throw new Error(diagnostic.message, { cause: error }) throw error diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts index 7e7436c49ba..710ef0dec01 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -43,7 +43,8 @@ export async function executeQuickBooksWebhookIngress( routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid) } catch (error) { ignored += 1 - logger.warn(`[${payload.requestId}] QuickBooks webhook event is not routable`, { + logger.warn('QuickBooks webhook event is not routable', { + requestId: payload.requestId, error: getErrorMessage(error, 'Unknown error'), eventId: event.id, eventIndex, @@ -78,7 +79,8 @@ export async function executeQuickBooksWebhookIngress( else failed += 1 } catch (error) { failed += 1 - logger.error(`[${payload.requestId}] QuickBooks webhook target dispatch failed`, { + logger.error('QuickBooks webhook target dispatch failed', { + requestId: payload.requestId, error, eventId: event.id, eventIndex, @@ -87,7 +89,8 @@ export async function executeQuickBooksWebhookIngress( } } - logger.info(`[${payload.requestId}] QuickBooks webhook event completed`, { + logger.info('QuickBooks webhook event completed', { + requestId: payload.requestId, eventId: event.id, eventIndex, ignored, @@ -96,7 +99,8 @@ export async function executeQuickBooksWebhookIngress( }) } catch (error) { failed += 1 - logger.error(`[${payload.requestId}] QuickBooks webhook target lookup failed`, { + logger.error('QuickBooks webhook target lookup failed', { + requestId: payload.requestId, error, eventId: event.id, eventIndex, @@ -104,7 +108,8 @@ export async function executeQuickBooksWebhookIngress( } } - logger.info(`[${payload.requestId}] QuickBooks webhook delivery completed`, { + logger.info('QuickBooks webhook delivery completed', { + requestId: payload.requestId, eventCount: payload.events.length, failed, ignored, diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 43465132a3c..533d5eb0063 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -247,7 +247,7 @@ async function applyScheduleUpdate( const row = updatedRows[0] outcome = { updated: Boolean(row), status: row?.status ?? null } } catch (error) { - logger.error(`[${requestId}] ${context}`, error, { cause: describeError(error) }) + logger.error(context, error, { requestId, cause: describeError(error) }) throw error } @@ -386,7 +386,8 @@ async function retryScheduleAfterInfraFailure({ const now = new Date() const retryAttempt = (payload.infraRetryCount || 0) + 1 if (retryAttempt > SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS) { - logger.error(`[${requestId}] Retryable infrastructure failures exhausted for schedule`, { + logger.error('Retryable infrastructure failures exhausted for schedule', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, retryAttempt, @@ -410,7 +411,8 @@ async function retryScheduleAfterInfraFailure({ const failureCause = cause ?? describeRetryableInfrastructureError(error) const errorMessage = message ?? (error ? toError(error).message : undefined) - logger.warn(`[${requestId}] Retryable infrastructure failure during scheduled setup`, { + logger.warn('Retryable infrastructure failure during scheduled setup', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, retryAttempt, @@ -442,10 +444,9 @@ async function calculateNextRunFromDeployment( const deployedData = await loadDeployedWorkflowState(payload.workflowId) return calculateNextRunTime(payload, deployedData.blocks as Record) } catch (error) { - logger.warn( - `[${requestId}] Unable to calculate nextRunAt for schedule ${payload.scheduleId}`, - error - ) + logger.warn(`Unable to calculate nextRunAt for schedule ${payload.scheduleId}`, error, { + requestId, + }) return null } } @@ -469,7 +470,7 @@ async function determineNextRunAfterError( } } } catch (workflowError) { - logger.error(`[${requestId}] Error retrieving workflow for next run calculation`, workflowError) + logger.error('Error retrieving workflow for next run calculation', workflowError, { requestId }) } return new Date(now.getTime() + 24 * 60 * 60 * 1000) @@ -546,7 +547,8 @@ async function runWorkflowExecution({ const blocks = deployedData.blocks const { deploymentVersionId } = deployedData if (payload.deploymentVersionId && deploymentVersionId !== payload.deploymentVersionId) { - logger.info(`[${requestId}] Loaded deployment no longer matches queued schedule, skipping`, { + logger.info('Loaded deployment no longer matches queued schedule, skipping', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, queuedDeploymentVersionId: payload.deploymentVersionId, @@ -558,12 +560,13 @@ async function runWorkflowExecution({ blocks: {} as Record, } } - logger.info(`[${requestId}] Loaded deployed workflow ${payload.workflowId}`) + logger.info(`Loaded deployed workflow ${payload.workflowId}`, { requestId }) if (payload.blockId) { if (!blocks[payload.blockId]) { logger.warn( - `[${requestId}] Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Skipping execution.` + `Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Skipping execution.`, + { requestId } ) return { @@ -628,7 +631,8 @@ async function runWorkflowExecution({ payload.deploymentVersionId && !(await isScheduleDeploymentVersionActive(payload.workflowId, payload.deploymentVersionId)) ) { - logger.info(`[${requestId}] Schedule deployment changed before execution, skipping`, { + logger.info('Schedule deployment changed before execution, skipping', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, deploymentVersionId: payload.deploymentVersionId, @@ -644,7 +648,8 @@ async function runWorkflowExecution({ if ( !(await isScheduleClaimCurrent(payload.scheduleId, claimedAt, payload.deploymentOperationId)) ) { - logger.info(`[${requestId}] Schedule claim changed before workflow core started, skipping`, { + logger.info('Schedule claim changed before workflow core started, skipping', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, claimedAt: claimedAt?.toISOString(), @@ -673,9 +678,7 @@ async function runWorkflowExecution({ timeoutMs !== undefined if (timedOut) { const timeoutErrorMessage = getTimeoutErrorMessage(timeoutMs) - logger.info(`[${requestId}] Scheduled workflow execution timed out`, { - timeoutMs, - }) + logger.info('Scheduled workflow execution timed out', { requestId, timeoutMs }) await loggingSession.markAsFailed(timeoutErrorMessage) } else { await handlePostExecutionPauseState({ @@ -699,7 +702,8 @@ async function runWorkflowExecution({ ) .limit(1) - logger.info(`[${requestId}] Workflow execution completed: ${payload.workflowId}`, { + logger.info(`Workflow execution completed: ${payload.workflowId}`, { + requestId, success: executionResult.success, executionTime: executionResult.metadata?.duration, }) @@ -716,7 +720,8 @@ async function runWorkflowExecution({ } catch (error: unknown) { if (!workflowCoreStarted && isRetryableInfrastructureError(error)) { const cause = describeRetryableInfrastructureError(error) - logger.warn(`[${requestId}] Retryable setup failure before scheduled workflow started`, { + logger.warn('Retryable setup failure before scheduled workflow started', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, cause, @@ -826,7 +831,8 @@ export async function executeScheduleJob( client: { surface: 'schedule', source: 'trigger' }, } return await runWithRequestContext(requestContext, async () => { - logger.info(`[${requestId}] Starting schedule execution`, { + logger.info('Starting schedule execution', { + requestId, scheduleId: payload.scheduleId, workflowId: payload.workflowId, executionId, @@ -868,7 +874,8 @@ export async function executeScheduleJob( .limit(1) if (!scheduleRecord) { - logger.info(`[${requestId}] Schedule no longer exists, skipping execution`, { + logger.info('Schedule no longer exists, skipping execution', { + requestId, scheduleId: payload.scheduleId, }) return @@ -879,19 +886,18 @@ export async function executeScheduleJob( (!scheduleRecord.lastQueuedAt || scheduleRecord.lastQueuedAt.getTime() !== claimedAt.getTime()) ) { - logger.info( - `[${requestId}] Schedule claim no longer matches payload, skipping execution`, - { - scheduleId: payload.scheduleId, - claimedAt: claimedAt.toISOString(), - currentLastQueuedAt: scheduleRecord.lastQueuedAt?.toISOString(), - } - ) + logger.info('Schedule claim no longer matches payload, skipping execution', { + requestId, + scheduleId: payload.scheduleId, + claimedAt: claimedAt.toISOString(), + currentLastQueuedAt: scheduleRecord.lastQueuedAt?.toISOString(), + }) return } if (scheduleRecord.archivedAt || scheduleRecord.status === 'disabled') { - logger.info(`[${requestId}] Schedule is archived or disabled, skipping execution`, { + logger.info('Schedule is archived or disabled, skipping execution', { + requestId, scheduleId: payload.scheduleId, }) await releaseClaim( @@ -917,14 +923,12 @@ export async function executeScheduleJob( .limit(1) if (!activeDeployment) { - logger.info( - `[${requestId}] Schedule deployment version is no longer active, skipping`, - { - scheduleId: payload.scheduleId, - workflowId: payload.workflowId, - deploymentVersionId: expectedDeploymentVersionId, - } - ) + logger.info('Schedule deployment version is no longer active, skipping', { + requestId, + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + deploymentVersionId: expectedDeploymentVersionId, + }) await releaseClaim( now, `Failed to release stale deployment schedule ${payload.scheduleId}` @@ -974,9 +978,9 @@ export async function executeScheduleJob( switch (statusCode) { case 401: { - logger.warn( - `[${requestId}] Authentication error during preprocessing, disabling schedule` - ) + logger.warn('Authentication error during preprocessing, disabling schedule', { + requestId, + }) await updateClaimedSchedule( { updatedAt: now, @@ -993,7 +997,8 @@ export async function executeScheduleJob( case 403: { logger.warn( - `[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessingError.message}` + `Authorization error during preprocessing, disabling schedule: ${preprocessingError.message}`, + { requestId } ) await updateClaimedSchedule( { @@ -1010,7 +1015,7 @@ export async function executeScheduleJob( } case 404: { - logger.warn(`[${requestId}] Workflow not found, disabling schedule`) + logger.warn('Workflow not found, disabling schedule', { requestId }) await updateClaimedSchedule( { updatedAt: now, @@ -1025,7 +1030,7 @@ export async function executeScheduleJob( } case 429: { - logger.warn(`[${requestId}] Rate limit exceeded, scheduling retry`) + logger.warn('Rate limit exceeded, scheduling retry', { requestId }) const retryDelay = 5 * 60 * 1000 const nextRetryAt = new Date(now.getTime() + retryDelay) @@ -1052,7 +1057,8 @@ export async function executeScheduleJob( const nextRunAt = (await calculateNextRunFromDeployment(payload, requestId)) ?? new Date(now.getTime() + 60 * 60 * 1000) - logger.warn(`[${requestId}] Usage limit exceeded, counting as failed run`, { + logger.warn('Usage limit exceeded, counting as failed run', { + requestId, scheduleId: payload.scheduleId, nextRunAt: nextRunAt.toISOString(), }) @@ -1076,7 +1082,7 @@ export async function executeScheduleJob( return } - logger.error(`[${requestId}] Preprocessing failed: ${preprocessingError.message}`) + logger.error(`Preprocessing failed: ${preprocessingError.message}`, { requestId }) const nextRunAt = await determineNextRunAfterError(payload, now, requestId) await updateClaimedSchedule( @@ -1091,7 +1097,7 @@ export async function executeScheduleJob( const { actorUserId, billingAttribution, workflowRecord } = preprocessResult if (!actorUserId || !billingAttribution || !workflowRecord) { - logger.error(`[${requestId}] Missing required preprocessing data`) + logger.error('Missing required preprocessing data', { requestId }) await releaseClaim( now, `Failed to release schedule ${payload.scheduleId} after missing preprocessing data` @@ -1103,7 +1109,7 @@ export async function executeScheduleJob( throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) } - logger.info(`[${requestId}] Executing scheduled workflow ${payload.workflowId}`) + logger.info(`Executing scheduled workflow ${payload.workflowId}`, { requestId }) try { const executionResult = await runWorkflowExecution({ @@ -1157,7 +1163,7 @@ export async function executeScheduleJob( } if (executionResult.status === 'success') { - logger.info(`[${requestId}] Workflow ${payload.workflowId} executed successfully`) + logger.info(`Workflow ${payload.workflowId} executed successfully`, { requestId }) const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) @@ -1169,7 +1175,7 @@ export async function executeScheduleJob( } if (executionResult.status === 'cancelled') { - logger.info(`[${requestId}] Workflow ${payload.workflowId} execution was cancelled`) + logger.info(`Workflow ${payload.workflowId} execution was cancelled`, { requestId }) const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) await updateClaimedSchedule( @@ -1179,7 +1185,7 @@ export async function executeScheduleJob( return } - logger.warn(`[${requestId}] Workflow ${payload.workflowId} execution failed`) + logger.warn(`Workflow ${payload.workflowId} execution failed`, { requestId }) const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) @@ -1190,8 +1196,9 @@ export async function executeScheduleJob( ) } catch (error: unknown) { logger.error( - `[${requestId}] Error executing scheduled workflow ${payload.workflowId}`, - loggingSession.projectDiagnosticError(error) + `Error executing scheduled workflow ${payload.workflowId}`, + loggingSession.projectDiagnosticError(error), + { requestId } ) const nextRunAt = await determineNextRunAfterError(payload, now, requestId) @@ -1209,7 +1216,8 @@ export async function executeScheduleJob( return } - logger.error(`[${requestId}] Error processing schedule ${payload.scheduleId}`, error, { + logger.error(`Error processing schedule ${payload.scheduleId}`, error, { + requestId, cause: describeError(error), }) await releaseClaim( @@ -1222,8 +1230,9 @@ export async function executeScheduleJob( // claim expires on its TTL and the next tick re-claims the schedule. Record the // exception on the span so it stays visible in traces without faulting the run. logger.error( - `[${requestId}] Failed to recover schedule ${payload.scheduleId} after error`, - recoveryError + `Failed to recover schedule ${payload.scheduleId} after error`, + recoveryError, + { requestId } ) trace.getActiveSpan()?.recordException(toError(recoveryError)) } diff --git a/apps/sim/background/table-run-dispatcher.ts b/apps/sim/background/table-run-dispatcher.ts index bb8593a6973..4e550717565 100644 --- a/apps/sim/background/table-run-dispatcher.ts +++ b/apps/sim/background/table-run-dispatcher.ts @@ -43,7 +43,7 @@ export const tableRunDispatcherTask = task({ try { await runDispatcherToCompletion(dispatchId, concurrency) } catch (err) { - logger.error(`[${dispatchId}] dispatcher loop failed`, { error: toError(err).message }) + logger.error('dispatcher loop failed', { dispatchId, error: toError(err).message }) throw err } }, diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts index 2ef1a7929cb..ff7a59c56af 100644 --- a/apps/sim/background/webhook-execution.test.ts +++ b/apps/sim/background/webhook-execution.test.ts @@ -495,16 +495,14 @@ describe('executeWebhookJob fault vs error handling', () => { workflowId: 'workflow-1', provider: 'gmail', }) - expect(webhookExecutionLogger.error).toHaveBeenCalledWith( - '[request-1] Webhook execution failed', - { - executionId: 'execution-1', - workflowId: 'workflow-1', - provider: 'gmail', - error: projectedError, - failureKind: 'internal', - } - ) + expect(webhookExecutionLogger.error).toHaveBeenCalledWith('Webhook execution failed', { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + provider: 'gmail', + error: projectedError, + failureKind: 'internal', + }) const loggerPayload = JSON.stringify(webhookExecutionLogger.error.mock.calls) expect(loggerPayload).not.toContain(secret) expect(loggerPayload).not.toContain('__var_') diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index c17befb2b47..660840b0cf2 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -261,8 +261,9 @@ async function processTriggerFileOutputs( processed[key] = processedFile } catch (error) { logger.error( - `[${context.requestId}] Error processing ${currentPath}`, - context.projectDiagnosticError(error, { path: currentPath }) + `Error processing ${currentPath}`, + context.projectDiagnosticError(error, { path: currentPath }), + { requestId: context.requestId } ) processed[key] = value } @@ -409,33 +410,29 @@ async function requeueWebhookExecutionAfterSetupFailure( }, }) - logger.warn( - `[${correlation.requestId}] Requeued webhook execution after retryable setup failure`, - { - workflowId: payload.workflowId, - webhookId: payload.webhookId, - executionId: correlation.executionId, - provider: payload.provider, - retryAttempt, - maxAttempts: WEBHOOK_INFRA_RETRY_MAX_ATTEMPTS, - delayMs, - jobId, - error: error.message, - cause: error.cause, - } - ) + logger.warn('Requeued webhook execution after retryable setup failure', { + requestId: correlation.requestId, + workflowId: payload.workflowId, + webhookId: payload.webhookId, + executionId: correlation.executionId, + provider: payload.provider, + retryAttempt, + maxAttempts: WEBHOOK_INFRA_RETRY_MAX_ATTEMPTS, + delayMs, + jobId, + error: error.message, + cause: error.cause, + }) return true } catch (enqueueError) { - logger.error( - `[${correlation.requestId}] Failed to requeue webhook execution after setup failure`, - { - workflowId: payload.workflowId, - webhookId: payload.webhookId, - executionId: correlation.executionId, - retryAttempt, - error: enqueueError, - } - ) + logger.error('Failed to requeue webhook execution after setup failure', { + requestId: correlation.requestId, + workflowId: payload.workflowId, + webhookId: payload.webhookId, + executionId: correlation.executionId, + retryAttempt, + error: enqueueError, + }) return false } } @@ -475,7 +472,8 @@ async function recordSetupFailureWithoutRequeue( skipCost: true, }) } catch (loggingError) { - logger.error(`[${correlation.requestId}] Failed to record terminal webhook setup failure`, { + logger.error('Failed to record terminal webhook setup failure', { + requestId: correlation.requestId, workflowId: payload.workflowId, executionId: correlation.executionId, error: loggingError, @@ -568,7 +566,8 @@ export async function executeWebhookJob( client: { surface: 'webhook', source: 'trigger' }, } return await runWithRequestContext(requestContext, async () => { - logger.info(`[${requestId}] Starting webhook execution`, { + logger.info('Starting webhook execution', { + requestId, webhookId: authenticatedPayload.webhookId, workflowId: authenticatedPayload.workflowId, provider: authenticatedPayload.provider, @@ -612,7 +611,8 @@ export async function executeWebhookJob( if (execution.outcome === 'in-progress') { // Ingress already acknowledged this delivery and nothing reads this job's result, // so waiting on the live holder would only pin the machine and queue slot. - logger.info(`[${requestId}] Skipping duplicate webhook delivery already in progress`, { + logger.info('Skipping duplicate webhook delivery already in progress', { + requestId, webhookId: authenticatedPayload.webhookId, workflowId: authenticatedPayload.workflowId, provider: authenticatedPayload.provider, @@ -775,7 +775,8 @@ async function handleExecutionResult( ctx.timeoutController.timeoutMs ) { const timeoutErrorMessage = getTimeoutErrorMessage(ctx.timeoutController.timeoutMs) - logger.info(`[${ctx.requestId}] Webhook execution timed out`, { + logger.info('Webhook execution timed out', { + requestId: ctx.requestId, timeoutMs: ctx.timeoutController.timeoutMs, }) await ctx.loggingSession.markAsFailed(timeoutErrorMessage) @@ -844,7 +845,8 @@ async function executeWebhookJobInternal( * condition, not a job fault: acknowledge and skip so workers do not * record a failed job (or burn retries) for work that must never run. */ - logger.info(`[${requestId}] Skipping webhook execution for undeployed workflow`, { + logger.info('Skipping webhook execution for undeployed workflow', { + requestId, workflowId: payload.workflowId, archived: Boolean(workflowRecord.archivedAt), }) @@ -903,9 +905,9 @@ async function executeWebhookJobInternal( const credentialAccountUserId = credentialAccount?.status === 'resolved' ? credentialAccount.userId : undefined if (credentialAccount?.status === 'missing') { - logger.warn( - `[${requestId}] Failed to resolve credential account for credential ${payload.credentialId}` - ) + logger.warn(`Failed to resolve credential account for credential ${payload.credentialId}`, { + requestId, + }) } if (!workflowData) { @@ -967,8 +969,9 @@ async function executeWebhookJobInternal( }) } catch (error) { logger.warn( - `[${requestId}] Failed to build webhook trace secret catalog`, - loggingSession.projectDiagnosticError(error) + 'Failed to build webhook trace secret catalog', + loggingSession.projectDiagnosticError(error), + { requestId } ) resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(secretScope) } @@ -1074,8 +1077,9 @@ async function executeWebhookJobInternal( } } catch (error) { logger.error( - `[${requestId}] Error processing trigger file outputs`, - loggingSession.projectDiagnosticError(error) + 'Error processing trigger file outputs', + loggingSession.projectDiagnosticError(error), + { requestId } ) } } @@ -1094,13 +1098,14 @@ async function executeWebhookJobInternal( }) } catch (error) { logger.error( - `[${requestId}] Error processing provider-specific files`, - loggingSession.projectDiagnosticError(error) + 'Error processing provider-specific files', + loggingSession.projectDiagnosticError(error), + { requestId } ) } } - logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`) + logger.debug(`Executing workflow for ${payload.provider} webhook`, { requestId }) const metadata: ExecutionMetadata = { requestId, @@ -1138,7 +1143,8 @@ async function executeWebhookJobInternal( */ if (payload.webhookReceivedAt !== undefined || payload.triggerTimestampMs !== undefined) { const now = Date.now() - logger.info(`[${requestId}] Webhook dispatch latency`, { + logger.info('Webhook dispatch latency', { + requestId, workflowId: payload.workflowId, provider: payload.provider, dispatchLatencyMs: @@ -1231,7 +1237,8 @@ async function executeWebhookJobInternal( workflowId: payload.workflowId, }) - logger.info(`[${requestId}] Webhook execution completed`, { + logger.info('Webhook execution completed', { + requestId, success: executionResult.success, workflowId: payload.workflowId, provider: payload.provider, @@ -1264,7 +1271,8 @@ async function executeWebhookJobInternal( ? describeRetryableInfrastructureError(error) : undefined if (retryableSetupCause && hasRemainingWebhookInfraRetry(payload)) { - logger.warn(`[${requestId}] Retryable setup failure before webhook workflow started`, { + logger.warn('Retryable setup failure before webhook workflow started', { + requestId, workflowId: payload.workflowId, provider: payload.provider, cause: retryableSetupCause, @@ -1272,12 +1280,14 @@ async function executeWebhookJobInternal( throw new RetryableSetupError(errorMessage, { cause: retryableSetupCause }) } - logFailureOnce(logger, `[${requestId}] Webhook execution failed`, error, { - metadata: () => - loggingSession.projectDiagnosticError(error, { + logFailureOnce(logger, 'Webhook execution failed', error, { + metadata: () => ({ + requestId, + ...loggingSession.projectDiagnosticError(error, { workflowId: payload.workflowId, provider: payload.provider, }), + }), executionId, }) @@ -1290,7 +1300,8 @@ async function executeWebhookJobInternal( provider: payload.provider, }) const completeWithDeletedScope = (scope: ExecutionScope) => { - logger.info(`[${requestId}] Webhook execution ended: its ${scope} was deleted`, { + logger.info(`Webhook execution ended: its ${scope} was deleted`, { + requestId, workflowId: payload.workflowId, executionId, }) @@ -1343,8 +1354,9 @@ async function executeWebhookJobInternal( }) } catch (loggingError) { logger.error( - `[${requestId}] Failed to complete logging session`, - loggingSession.projectDiagnosticError(loggingError) + 'Failed to complete logging session', + loggingSession.projectDiagnosticError(loggingError), + { requestId } ) } diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index 391d73b168f..0aa2c684e8a 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -220,7 +220,8 @@ export async function executeWorkflowJob( } return await runWithRequestContext({ requestId, ...payload.attribution }, async () => { - logger.info(`[${requestId}] Starting workflow execution job: ${workflowId}`, { + logger.info(`Starting workflow execution job: ${workflowId}`, { + requestId, userId: payload.userId, triggerType: payload.triggerType, executionId, @@ -252,7 +253,8 @@ export async function executeWorkflowJob( }) if (!preprocessResult.success) { - logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`, { + logger.error(`Preprocessing failed: ${preprocessResult.error?.message}`, { + requestId, workflowId, statusCode: preprocessResult.error?.statusCode, }) @@ -267,7 +269,7 @@ export async function executeWorkflowJob( throw new Error(`Workflow ${workflowId} has no associated workspace`) } - logger.info(`[${requestId}] Preprocessing passed. Using actor: ${actorUserId}`) + logger.info(`Preprocessing passed. Using actor: ${actorUserId}`, { requestId }) const workflow = preprocessResult.workflowRecord! @@ -319,7 +321,8 @@ export async function executeWorkflowJob( timeoutController.timeoutMs ) { const timeoutErrorMessage = getTimeoutErrorMessage(timeoutController.timeoutMs) - logger.info(`[${requestId}] Workflow execution timed out`, { + logger.info('Workflow execution timed out', { + requestId, timeoutMs: timeoutController.timeoutMs, }) await loggingSession.markAsFailed(timeoutErrorMessage) @@ -332,7 +335,8 @@ export async function executeWorkflowJob( if (timeoutError) throw timeoutError - logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, { + logger.info(`Workflow execution completed: ${workflowId}`, { + requestId, success: result.success, executionTime: result.metadata?.duration, executionId, @@ -347,13 +351,18 @@ export async function executeWorkflowJob( metadata: payload.metadata, } } catch (error: unknown) { - logFailureOnce(logger, `[${requestId}] Workflow execution failed: ${workflowId}`, error, { - metadata: () => loggingSession.projectDiagnosticError(error), + logFailureOnce(logger, 'Workflow execution failed', error, { + metadata: () => ({ + requestId, + workflowId, + ...loggingSession.projectDiagnosticError(error), + }), executionId, }) const completeWithDeletedScope = (scope: ExecutionScope) => { - logger.info(`[${requestId}] Workflow execution ended: its ${scope} was deleted`, { + logger.info(`Workflow execution ended: its ${scope} was deleted`, { + requestId, workflowId, executionId, }) diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts index 77b4c3fa782..290377dece8 100644 --- a/apps/sim/blocks/blocks/credential.ts +++ b/apps/sim/blocks/blocks/credential.ts @@ -28,7 +28,7 @@ export const CredentialBlock: BlockConfig = { - Use the Provider filter to narrow results to specific services (e.g. Gmail, Slack). - Use "List Organization Accounts" with Providers selected and Email blank to discover all accessible accounts for those integrations. - Organization lists return one page at a time. While hasMore is true, pass nextCursor as Cursor with the same filters to get every matching account. - - "Find Organization Account" requires an exact enrollment email and provider, and fails unless exactly one active account matches. + - "Find Organization Account" requires an exact enrollment email and provider, and returns that person's first connected account for the provider, or fails if there is none. Use "List Organization Accounts" to reach their other accounts. - Outputs contain account identities and credential references, never secret values. - To switch credentials across environments, replace the single Credential block rather than updating every downstream block. `, diff --git a/apps/sim/blocks/blocks/firecrawl.ts b/apps/sim/blocks/blocks/firecrawl.ts index 07a581bbd1c..323f3b8a4b3 100644 --- a/apps/sim/blocks/blocks/firecrawl.ts +++ b/apps/sim/blocks/blocks/firecrawl.ts @@ -53,6 +53,7 @@ export const FirecrawlBlock: BlockConfig = { ], parse: [{ text: 'Parse', field: DOCUMENT_FIELD, core: true, after: 'into markdown' }], credit_usage: ['Read remaining credit balance'], + enrich: [{ text: 'Look up', field: 'enrichmentType', core: true }], }, }, }, @@ -75,9 +76,93 @@ export const FirecrawlBlock: BlockConfig = { { label: 'Agent', id: 'agent' }, { label: 'Parse Document', id: 'parse' }, { label: 'Credit Usage', id: 'credit_usage' }, + { label: 'Enrich Contact or Company', id: 'enrich' }, ], value: () => 'scrape', }, + { + id: 'enrichmentType', + title: 'Enrichment Type', + type: 'dropdown', + options: [ + { label: 'Work Email', id: 'work_email' }, + { label: 'Phone Number', id: 'phone' }, + { label: 'LinkedIn Profile', id: 'linkedin' }, + { label: 'Company Information', id: 'company' }, + { label: 'Company Domain', id: 'company_domain' }, + ], + value: () => 'work_email', + condition: { field: 'operation', value: 'enrich' }, + required: true, + }, + { + id: 'fullName', + title: 'Full Name', + type: 'short-input', + placeholder: 'Full name of the person', + condition: { + field: 'operation', + value: 'enrich', + and: { field: 'enrichmentType', value: ['work_email', 'phone', 'linkedin'] }, + }, + }, + { + id: 'companyDomain', + title: 'Company Domain', + type: 'short-input', + placeholder: 'example.com', + required: { + field: 'operation', + value: 'enrich', + and: { field: 'enrichmentType', value: 'company' }, + }, + condition: { + field: 'operation', + value: 'enrich', + and: { field: 'enrichmentType', value: ['work_email', 'phone', 'linkedin', 'company'] }, + }, + }, + { + id: 'companyName', + title: 'Company Name', + type: 'short-input', + placeholder: 'Company name', + required: { + field: 'operation', + value: 'enrich', + and: { field: 'enrichmentType', value: 'company_domain' }, + }, + condition: { + field: 'operation', + value: 'enrich', + and: { + field: 'enrichmentType', + value: ['work_email', 'phone', 'linkedin', 'company_domain'], + }, + }, + }, + { + id: 'linkedinUrl', + title: 'LinkedIn Profile URL', + type: 'short-input', + placeholder: 'https://www.linkedin.com/in/...', + condition: { + field: 'operation', + value: 'enrich', + and: { field: 'enrichmentType', value: ['work_email', 'phone'] }, + }, + }, + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'Email address for reverse lookup', + condition: { + field: 'operation', + value: 'enrich', + and: { field: 'enrichmentType', value: ['linkedin'] }, + }, + }, { id: 'fileUpload', title: 'Document', @@ -470,6 +555,7 @@ Example 2 - Product Data: 'firecrawl_agent', 'firecrawl_parse', 'firecrawl_credit_usage', + 'firecrawl_enrich', ], config: { tool: (params) => { @@ -500,6 +586,8 @@ Example 2 - Product Data: return 'firecrawl_parse' case 'credit_usage': return 'firecrawl_credit_usage' + case 'enrich': + return 'firecrawl_enrich' default: return 'firecrawl_scrape' } @@ -714,6 +802,15 @@ Example 2 - Product Data: if (jobId) result.jobId = jobId break + case 'enrich': + result.enrichmentType = params.enrichmentType + result.fullName = params.fullName + result.companyDomain = params.companyDomain + result.companyName = params.companyName + result.linkedinUrl = params.linkedinUrl + result.email = params.email + break + case 'credit_usage': break } @@ -723,6 +820,12 @@ Example 2 - Product Data: }, }, inputs: { + enrichmentType: { type: 'string', description: 'Contact or company lookup type' }, + fullName: { type: 'string', description: 'Full name of the person' }, + companyDomain: { type: 'string', description: 'Company website domain' }, + companyName: { type: 'string', description: 'Company name' }, + linkedinUrl: { type: 'string', description: 'LinkedIn profile URL' }, + email: { type: 'string', description: 'Email address for reverse lookup' }, apiKey: { type: 'string', description: 'Firecrawl API key' }, operation: { type: 'string', description: 'Operation to perform' }, url: { type: 'string', description: 'Target website URL' }, @@ -775,6 +878,14 @@ Example 2 - Product Data: zeroDataRetention: { type: 'boolean', description: 'Enable zero data retention' }, }, outputs: { + email: { type: 'string', description: 'Work email, when found' }, + emailVerified: { type: 'boolean', description: 'Whether the work email is mailbox-verified' }, + phone: { type: 'string', description: 'Phone number, preferring mobile when available' }, + linkedinUrl: { type: 'string', description: 'LinkedIn profile URL, when found' }, + domain: { type: 'string', description: 'Company website domain, when found' }, + employeeCount: { type: 'string', description: 'Company headcount or size range' }, + description: { type: 'string', description: 'Company description' }, + statusCode: { type: 'number', description: 'Enrichment provider or HTTP response status code' }, // Scrape output markdown: { type: 'string', description: 'Page content markdown' }, html: { type: 'string', description: 'Raw HTML content' }, diff --git a/apps/sim/blocks/blocks/flow.ts b/apps/sim/blocks/blocks/flow.ts new file mode 100644 index 00000000000..cc5b7732b12 --- /dev/null +++ b/apps/sim/blocks/blocks/flow.ts @@ -0,0 +1,1184 @@ +import { ApiIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' + +export const FlowBlock: BlockConfig = { + type: 'flow', + name: 'Flow Engineering', + description: 'Manage engineering requirements and traceability', + longDescription: + 'Read and update Flow Engineering projects, requirements, tests, and other entities through its customer API. Search current project data, inspect categories and relationships, manage comments, and make controlled changes on a working branch using your connected account permissions.', + docsLink: 'https://docs.sim.ai/integrations/flow', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#2448C7', + icon: ApiIcon, + authMode: AuthMode.ApiKey, + canvasPresentation: { + defaultTitle: 'Flow Engineering', + sentences: { + byOperation: { + list_projects: ['List Projects'], + get_project: [ + { + text: 'Read project', + field: ['projectSelector', 'projectId'], + core: true, + }, + ], + get_data_model: [ + { + text: 'Read data model of', + field: ['projectSelector', 'projectId'], + core: true, + }, + ], + list_branches: ['List Branches'], + create_branch: ['Create Branch'], + search_entities: [ + { + text: 'Search entities for', + field: 'query', + core: true, + }, + ], + list_entities: ['List Entities'], + get_entities: [ + { + text: 'Read entities', + field: 'entityIds', + core: true, + }, + ], + create_entity: [ + { + text: 'Create entity', + field: 'name', + core: true, + }, + ], + create_entities: ['Create Entities'], + set_value: [ + { + text: 'Set field', + field: 'fieldKey', + core: true, + }, + { + text: 'on', + field: 'entityId', + core: true, + }, + ], + set_values: ['Set Values'], + delete_entity: [ + { + text: 'Delete entity', + field: 'entityId', + core: true, + }, + ], + restore_entity: [ + { + text: 'Restore entity', + field: 'entityId', + core: true, + }, + ], + add_link: [ + { + text: 'Link entity', + field: 'entityId', + core: true, + }, + { + text: 'to', + field: 'destinationId', + core: true, + }, + ], + remove_link: [ + { + text: 'Unlink entity', + field: 'entityId', + core: true, + }, + { + text: 'from', + field: 'destinationId', + core: true, + }, + ], + list_comments: [ + { + text: 'Read comments on', + field: 'entityId', + core: true, + }, + ], + get_comment: [ + { + text: 'Read comment', + field: 'commentId', + core: true, + }, + ], + create_comment: [ + { + text: 'Post comment', + field: 'text', + core: true, + }, + { + text: 'on', + field: 'entityId', + core: true, + }, + ], + update_comment: [ + { + text: 'Update comment', + field: 'commentId', + core: true, + }, + ], + delete_comment: [ + { + text: 'Delete comment', + field: 'commentId', + core: true, + }, + ], + filter_entities: ['Filter Entities'], + get_entity_history: [ + { + text: 'Read history of', + field: 'entityIds', + core: true, + }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + value: () => 'search_entities', + title: 'Operation', + type: 'dropdown', + options: [ + { + label: 'List Projects', + id: 'list_projects', + }, + { + label: 'Get Project', + id: 'get_project', + }, + { + label: 'Get Data Model', + id: 'get_data_model', + }, + { + label: 'List Branches', + id: 'list_branches', + }, + { + label: 'Create Branch', + id: 'create_branch', + }, + { + label: 'Search Entities', + id: 'search_entities', + }, + { + label: 'List Entities', + id: 'list_entities', + }, + { + label: 'Get Entities', + id: 'get_entities', + }, + { + label: 'Create Entity', + id: 'create_entity', + }, + { + label: 'Create Entities', + id: 'create_entities', + }, + { + label: 'Set Value', + id: 'set_value', + }, + { + label: 'Set Values', + id: 'set_values', + }, + { + label: 'Delete Entity', + id: 'delete_entity', + }, + { + label: 'Restore Entity', + id: 'restore_entity', + }, + { + label: 'Add Link', + id: 'add_link', + }, + { + label: 'Remove Link', + id: 'remove_link', + }, + { + label: 'List Comments', + id: 'list_comments', + }, + { + label: 'Get Comment', + id: 'get_comment', + }, + { + label: 'Create Comment', + id: 'create_comment', + }, + { + label: 'Update Comment', + id: 'update_comment', + }, + { + label: 'Delete Comment', + id: 'delete_comment', + }, + { + label: 'Filter Entities', + id: 'filter_entities', + }, + { + label: 'Get Entity History', + id: 'get_entity_history', + }, + ], + }, + { + id: 'credential', + title: 'Flow Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + serviceId: 'flow', + credentialKind: 'service-account', + required: true, + }, + { + id: 'projectSelector', + title: 'Project', + type: 'dropdown', + canonicalParamId: 'projectId', + mode: 'basic', + placeholder: 'Select project', + condition: { + field: 'operation', + value: [ + 'get_project', + 'get_data_model', + 'list_branches', + 'create_branch', + 'search_entities', + 'list_entities', + 'get_entities', + 'create_entity', + 'create_entities', + 'set_value', + 'set_values', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'get_comment', + 'create_comment', + 'update_comment', + 'delete_comment', + 'filter_entities', + 'get_entity_history', + ], + }, + required: { + field: 'operation', + value: [ + 'get_project', + 'get_data_model', + 'list_branches', + 'create_branch', + 'search_entities', + 'list_entities', + 'get_entities', + 'create_entity', + 'create_entities', + 'set_value', + 'set_values', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'get_comment', + 'create_comment', + 'update_comment', + 'delete_comment', + 'filter_entities', + 'get_entity_history', + ], + }, + selectorKey: 'flow.projects', + dependsOn: ['credential'], + }, + { + id: 'projectId', + title: 'Project ID', + type: 'short-input', + canonicalParamId: 'projectId', + mode: 'advanced', + placeholder: 'Enter project ID', + condition: { + field: 'operation', + value: [ + 'get_project', + 'get_data_model', + 'list_branches', + 'create_branch', + 'search_entities', + 'list_entities', + 'get_entities', + 'create_entity', + 'create_entities', + 'set_value', + 'set_values', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'get_comment', + 'create_comment', + 'update_comment', + 'delete_comment', + 'filter_entities', + 'get_entity_history', + ], + }, + required: { + field: 'operation', + value: [ + 'get_project', + 'get_data_model', + 'list_branches', + 'create_branch', + 'search_entities', + 'list_entities', + 'get_entities', + 'create_entity', + 'create_entities', + 'set_value', + 'set_values', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'get_comment', + 'create_comment', + 'update_comment', + 'delete_comment', + 'filter_entities', + 'get_entity_history', + ], + }, + dependsOn: ['credential'], + }, + { + id: 'categorySelector', + title: 'Category', + type: 'dropdown', + canonicalParamId: 'entityType', + mode: 'basic', + placeholder: 'Select category', + condition: { + field: 'operation', + value: ['search_entities', 'list_entities', 'create_entity'], + }, + required: { + field: 'operation', + value: ['create_entity'], + }, + selectorKey: 'flow.categories', + dependsOn: ['credential', 'projectId'], + }, + { + id: 'entityType', + title: 'Category ID', + type: 'short-input', + canonicalParamId: 'entityType', + mode: 'advanced', + placeholder: 'Enter category ID', + condition: { + field: 'operation', + value: ['search_entities', 'list_entities', 'create_entity'], + }, + required: { + field: 'operation', + value: ['create_entity'], + }, + dependsOn: ['credential', 'projectId'], + }, + { + id: 'branchSelector', + title: 'Branch', + type: 'dropdown', + canonicalParamId: 'branchId', + mode: 'basic', + placeholder: 'Select branch (defaults to Base)', + condition: { + field: 'operation', + value: [ + 'search_entities', + 'list_entities', + 'get_entities', + 'create_entity', + 'create_entities', + 'set_value', + 'set_values', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'create_comment', + 'filter_entities', + 'get_entity_history', + ], + }, + required: false, + selectorKey: 'flow.branches', + dependsOn: ['credential', 'projectId'], + }, + { + id: 'branchId', + title: 'Branch ID', + type: 'short-input', + canonicalParamId: 'branchId', + mode: 'advanced', + placeholder: 'master (Base) or branch ID', + condition: { + field: 'operation', + value: [ + 'search_entities', + 'list_entities', + 'get_entities', + 'create_entity', + 'create_entities', + 'set_value', + 'set_values', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'create_comment', + 'filter_entities', + 'get_entity_history', + ], + }, + required: false, + dependsOn: ['credential', 'projectId'], + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'search_entities', + 'list_entities', + 'filter_entities', + 'get_entity_history', + ], + }, + required: false, + mode: 'advanced', + placeholder: 'Defaults to the operation page size', + }, + { + id: 'branchName', + title: 'Branch Name', + type: 'short-input', + condition: { + field: 'operation', + value: ['create_branch'], + }, + required: false, + mode: 'advanced', + placeholder: 'Enter branch name', + }, + { + id: 'parentId', + title: 'Parent Branch ID', + type: 'short-input', + condition: { + field: 'operation', + value: ['create_branch'], + }, + required: false, + mode: 'advanced', + placeholder: 'master (Base) or parent branch ID', + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + condition: { + field: 'operation', + value: ['create_branch'], + }, + required: false, + mode: 'advanced', + placeholder: 'Enter branch description', + }, + { + id: 'isVariant', + title: 'Variant', + type: 'dropdown', + condition: { + field: 'operation', + value: ['create_branch'], + }, + required: false, + mode: 'advanced', + options: [ + { + label: 'Yes', + id: 'true', + }, + { + label: 'No', + id: 'false', + }, + ], + }, + { + id: 'query', + title: 'Query', + type: 'short-input', + condition: { + field: 'operation', + value: ['search_entities'], + }, + required: { + field: 'operation', + value: ['search_entities'], + }, + placeholder: 'Search text or exact Flow ID', + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + condition: { + field: 'operation', + value: ['list_entities', 'filter_entities', 'get_entity_history'], + }, + required: false, + mode: 'advanced', + placeholder: 'Enter cursor from the previous page', + }, + { + id: 'entityIds', + title: 'Entity IDs', + type: 'short-input', + condition: { + field: 'operation', + value: ['get_entities', 'get_entity_history'], + }, + required: { + field: 'operation', + value: ['get_entities', 'get_entity_history'], + }, + placeholder: 'entity-id-1, entity-id-2', + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + condition: { + field: 'operation', + value: ['create_entity'], + }, + required: { + field: 'operation', + value: ['create_entity'], + }, + placeholder: 'Enter entity name', + }, + { + id: 'entities', + title: 'Entities', + type: 'code', + condition: { + field: 'operation', + value: ['create_entities'], + }, + required: { + field: 'operation', + value: ['create_entities'], + }, + placeholder: '[{"entityType":"category-id","name":"Operating temperature"}]', + language: 'json', + wandConfig: { + enabled: true, + generationType: 'json-array', + prompt: + 'Generate valid JSON for Flow entities: Array of 1\u2013100 entities: [{entityType, name, id?}]. IDs may be omitted for server generation. Return only JSON. Use only supplied category IDs and field keys.', + placeholder: 'Describe the entities to generate', + }, + }, + { + id: 'entityId', + title: 'Entity ID', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'set_value', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'get_comment', + 'create_comment', + 'update_comment', + 'delete_comment', + ], + }, + required: { + field: 'operation', + value: [ + 'set_value', + 'delete_entity', + 'restore_entity', + 'add_link', + 'remove_link', + 'list_comments', + 'get_comment', + 'create_comment', + 'update_comment', + 'delete_comment', + ], + }, + placeholder: 'Enter entity or original comment ID', + }, + { + id: 'fieldKey', + title: 'Field Key', + type: 'short-input', + condition: { + field: 'operation', + value: ['set_value'], + }, + required: { + field: 'operation', + value: ['set_value'], + }, + placeholder: 'name', + }, + { + id: 'value', + title: 'Value', + type: 'code', + condition: { + field: 'operation', + value: ['set_value'], + }, + required: { + field: 'operation', + value: ['set_value'], + }, + placeholder: '"Updated name"', + language: 'json', + wandConfig: { + enabled: true, + generationType: 'json-value', + prompt: + 'Generate valid JSON for Flow value: Field value as JSON, matching the data model: string, number, boolean, array, object, or null. Use only supplied category IDs and field keys. Return ONLY one raw JSON value, without markdown fences or prose.', + placeholder: 'Describe the value to generate', + }, + }, + { + id: 'updates', + title: 'Updates', + type: 'code', + condition: { + field: 'operation', + value: ['set_values'], + }, + required: { + field: 'operation', + value: ['set_values'], + }, + placeholder: '[{"entityId":"entity-id","key":"name","value":"Operating range"}]', + language: 'json', + wandConfig: { + enabled: true, + generationType: 'json-array', + prompt: + 'Generate valid JSON for Flow updates: Array of 1\u2013100 field updates: [{entityId, key, value}]. Return only JSON. Use only supplied category IDs and field keys.', + placeholder: 'Describe the updates to generate', + }, + }, + { + id: 'destinationId', + title: 'Destination ID', + type: 'short-input', + condition: { + field: 'operation', + value: ['add_link', 'remove_link'], + }, + required: { + field: 'operation', + value: ['add_link', 'remove_link'], + }, + placeholder: 'Enter downstream entity ID', + }, + { + id: 'includeResolved', + title: 'Include Resolved', + type: 'dropdown', + condition: { + field: 'operation', + value: ['list_comments'], + }, + required: false, + mode: 'advanced', + options: [ + { + label: 'Yes', + id: 'true', + }, + { + label: 'No', + id: 'false', + }, + ], + }, + { + id: 'commentId', + title: 'Comment ID', + type: 'short-input', + condition: { + field: 'operation', + value: ['get_comment', 'update_comment', 'delete_comment'], + }, + required: { + field: 'operation', + value: ['get_comment', 'update_comment', 'delete_comment'], + }, + placeholder: 'Enter comment ID', + }, + { + id: 'text', + title: 'Text', + type: 'long-input', + condition: { + field: 'operation', + value: ['create_comment', 'update_comment'], + }, + required: { + field: 'operation', + value: ['create_comment'], + }, + placeholder: 'Enter comment text', + }, + { + id: 'resolved', + title: 'Resolved', + type: 'dropdown', + condition: { + field: 'operation', + value: ['update_comment'], + }, + required: false, + mode: 'advanced', + options: [ + { + label: 'Yes', + id: 'true', + }, + { + label: 'No', + id: 'false', + }, + ], + }, + { + id: 'filter', + title: 'Filter', + type: 'code', + condition: { + field: 'operation', + value: ['filter_entities'], + }, + required: { + field: 'operation', + value: ['filter_entities'], + }, + placeholder: + '{"kind":"field","category_id":"category-id","field_key":"name","operation":"contains","value":"temperature"}', + language: 'json', + wandConfig: { + enabled: true, + generationType: 'json-object', + prompt: + 'Generate valid JSON for Flow filter: Filter object: {kind:"type",operation:"overlaps",values:[categoryId]}, {kind:"field",category_id,field_key,operation:"contains",value}, or {kind:"group",operator:"and"|"or",conditions:[...]}. Use data-model IDs and field-compatible operators; at most 50 nodes and 5 group levels. Return only JSON. Use only supplied category IDs and field keys.', + placeholder: 'Describe the filter to generate', + }, + }, + { + id: 'timezone', + title: 'Timezone', + type: 'short-input', + condition: { + field: 'operation', + value: ['filter_entities'], + }, + required: false, + mode: 'advanced', + placeholder: 'UTC', + }, + ], + tools: { + access: [ + 'flow_list_projects', + 'flow_get_project', + 'flow_get_data_model', + 'flow_list_branches', + 'flow_create_branch', + 'flow_search_entities', + 'flow_list_entities', + 'flow_get_entities', + 'flow_create_entity', + 'flow_create_entities', + 'flow_set_value', + 'flow_set_values', + 'flow_delete_entity', + 'flow_restore_entity', + 'flow_add_link', + 'flow_remove_link', + 'flow_list_comments', + 'flow_get_comment', + 'flow_create_comment', + 'flow_update_comment', + 'flow_delete_comment', + 'flow_filter_entities', + 'flow_get_entity_history', + ], + config: { + tool: (params) => `flow_${params.operation || 'search_entities'}`, + params: (params) => { + const result: Record = {} + result.limit = + params.limit == null || (typeof params.limit === 'string' && !params.limit.trim()) + ? undefined + : Number(params.limit) + for (const key of ['includeResolved', 'resolved', 'isVariant']) { + result[key] = + params[key] == null || params[key] === '' + ? undefined + : params[key] === true || params[key] === 'true' + } + return result + }, + }, + }, + inputs: { + operation: { + type: 'string', + description: 'Operation to perform', + }, + oauthCredential: { + type: 'string', + description: 'Saved Flow personal API key credential', + }, + limit: { + type: 'number', + description: 'Maximum results in this page', + }, + projectId: { + type: 'string', + description: 'Project UUID returned by List Projects', + }, + branchName: { + type: 'string', + description: 'Name of the new branch', + }, + parentId: { + type: 'string', + description: 'Parent branch ID; omit for Base', + }, + description: { + type: 'string', + description: 'Description of the new branch', + }, + isVariant: { + type: 'boolean', + description: 'Create a variant branch', + }, + branchId: { + type: 'string', + description: 'Branch ID; omit or use master for Base', + }, + entityType: { + type: 'string', + description: 'Category ID returned by Get Data Model', + }, + query: { + type: 'string', + description: + 'Search names and supported field values, or an exact Flow/Human ID; at most 1024 characters', + }, + cursor: { + type: 'string', + description: 'Opaque continuation cursor from the previous page', + }, + entityIds: { + type: 'string', + description: 'Comma-separated entity IDs; up to 100 per read or 500 per history page', + }, + name: { + type: 'string', + description: 'Entity name', + }, + entities: { + type: 'json', + description: + 'Array of 1\u2013100 entities: [{entityType, name, id?}]. IDs may be omitted for server generation', + }, + entityId: { + type: 'string', + description: 'Entity ID, or the original comment ID when reading or posting thread replies', + }, + fieldKey: { + type: 'string', + description: 'Stored field key returned by Get Data Model', + }, + value: { + type: 'string', + description: + 'Field value as JSON, matching the data model: string, number, boolean, array, object, or null', + }, + updates: { + type: 'json', + description: 'Array of 1\u2013100 field updates: [{entityId, key, value}]', + }, + destinationId: { + type: 'string', + description: 'ID of the downstream entity to link or unlink', + }, + includeResolved: { + type: 'boolean', + description: 'Include resolved comments', + }, + commentId: { + type: 'string', + description: "Comment ID; entityId must be the comment's returned entity_id", + }, + text: { + type: 'string', + description: 'Replacement plain-text comment body', + }, + resolved: { + type: 'boolean', + description: 'Resolve the thread with true or reopen it with false', + }, + filter: { + type: 'json', + description: + 'Filter object: {kind:"type",operation:"overlaps",values:[categoryId]}, {kind:"field",category_id,field_key,operation:"contains",value}, or {kind:"group",operator:"and"|"or",conditions:[...]}. Use data-model IDs and field-compatible operators; at most 50 nodes and 5 group levels', + }, + timezone: { + type: 'string', + description: 'IANA timezone for date filters; defaults to UTC', + }, + }, + outputs: { + projects: { + type: 'json', + description: 'Projects (id, name, slug, created_at, ai_model)', + }, + project: { + type: 'json', + description: 'Project (id, name, slug, created_at, ai_model)', + }, + categories: { + type: 'json', + description: 'Categories (id, name, categoryType, fields with key, name, type and tags)', + }, + branches: { + type: 'json', + description: 'Branches (id, name, number, parent, protection and review state)', + }, + matches: { + type: 'json', + description: + 'Compact search matches (entity_id, name, entity_type, human_id, legacy_human_id)', + }, + entities: { + type: 'json', + description: 'Entity states (id, type, values, formulas, links, timestamps)', + }, + entity: { + type: 'json', + description: 'Entity state (id, type, values, formulas, links, timestamps)', + }, + comments: { + type: 'json', + description: 'Comments (id, entity_id, author_id, text, resolved, created_at, branch_id)', + }, + comment: { + type: 'json', + description: 'Comment (id, entity_id, author_id, text, resolved, created_at, branch_id)', + }, + entries: { + type: 'json', + description: 'History entries (id, timestamp, branch_id, actor, change, reason)', + }, + entityIds: { + type: 'json', + description: 'Entity IDs returned by Filter Entities', + }, + nextCursor: { + type: 'string', + description: 'History or filter cursor; null when finished', + }, + nextPageStart: { + type: 'string', + description: 'Entity listing cursor for the next page', + }, + moreAvailable: { + type: 'boolean', + description: 'Whether to fetch the next entity page', + }, + id: { + type: 'string', + description: 'Created branch ID', + }, + number: { + type: 'number', + description: 'Created branch number', + }, + }, +} + +export const FlowBlockMeta = { + tags: ['project-management', 'automation', 'version-control'], + url: 'https://www.flowengineering.com', + templates: [ + { + icon: ApiIcon, + title: 'Flow requirement lookup', + prompt: + 'When asked about a component, search its Flow project, read the matching entities, and summarize requirements with their IDs.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + { + icon: ApiIcon, + title: 'Flow traceability review', + prompt: + 'Read the Flow data model and selected requirements, follow their links, and report missing test coverage without changing the project.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + { + icon: ApiIcon, + title: 'Flow requirement drafts', + prompt: + 'On request, create a working branch in Flow, draft requirements in a selected category, and set their fields using the project data model.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + { + icon: ApiIcon, + title: 'Flow review digest', + prompt: + 'On a schedule, list Flow project branches and summarize the branches ready for review and their unresolved discussion threads.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + { + icon: ApiIcon, + title: 'Flow field update', + prompt: + 'From a supplied change list, read the current Flow entity values, apply the approved field updates as one batch, and report the changed IDs.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + { + icon: ApiIcon, + title: 'Flow discussion summary', + prompt: + 'When given a Flow entity, read its comment threads and their direct replies, summarize open decisions, and post a follow-up only when requested.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + { + icon: ApiIcon, + title: 'Flow change history', + prompt: + 'When investigating a requirement change, read its paginated Flow history on the selected branch and explain the actors, changes, and timestamps.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['project-management'], + }, + ], + skills: [ + { + name: 'find-engineering-requirements', + description: 'Find current requirements using project-aware search.', + content: + '# Find Engineering Requirements\n\n## Steps\n1. List accessible projects and select the requested project.\n2. Read its data model to identify the requirement category.\n3. Search entities on the requested branch.\n4. Read matching entity IDs for complete values and links.\n\n## Output\nSummarize the requirements with IDs and the selected branch. A search is bounded; do not claim it exports every requirement.', + }, + { + name: 'review-requirement-traceability', + description: 'Inspect links between requirements and verification artifacts.', + content: + '# Review Requirement Traceability\n\n## Steps\n1. Read the project data model and selected requirements.\n2. Inspect their inbound and outbound links.\n3. Read linked entities on the same branch.\n4. Identify requirements without linked verification artifacts using the project categories.\n\n## Output\nReport observed coverage and missing links. Distinguish missing evidence from a failed verification.', + }, + { + name: 'draft-requirements-on-a-branch', + description: 'Create proposed requirements on a working branch.', + content: + '# Draft Requirements On A Branch\n\n## Steps\n1. Read the project data model and existing requirements.\n2. Create a working branch.\n3. After the user approves the proposal, create the entities with documented category IDs.\n4. Set their field values on the same branch and read back the result.\n\n## Output\nReturn the branch and entity IDs for review. Do not claim the branch is merged.', + }, + { + name: 'summarize-engineering-discussions', + description: 'Summarize an item discussion and its direct replies.', + content: + '# Summarize Engineering Discussions\n\n## Steps\n1. List comments on the selected entity and branch.\n2. Read each original comment ID to fetch direct replies.\n3. Separate open decisions from resolved threads.\n4. If a reply is requested, post to the original comment ID on the same branch.\n\n## Output\nProvide a concise summary tied to comment IDs and the relevant requirement.', + }, + { + name: 'audit-requirement-changes', + description: 'Trace engineering changes through the documented history API.', + content: + '# Audit Requirement Changes\n\n## Steps\n1. Identify the project, branch, and entity IDs.\n2. Read history pages, preserving the same scope and passing each nextCursor.\n3. If a rebase invalidates the cursor, restart from the first page.\n4. Correlate changes with the current entity values.\n\n## Output\nReport actors, timestamps, and field or relationship changes without treating a partial history page as complete.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/gauge.ts b/apps/sim/blocks/blocks/gauge.ts new file mode 100644 index 00000000000..af734b73eb6 --- /dev/null +++ b/apps/sim/blocks/blocks/gauge.ts @@ -0,0 +1,1224 @@ +import { GaugeIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' + +export const GaugeBlock: BlockConfig = { + type: 'gauge', + name: 'Gauge', + description: 'Track AI visibility and manage Gauge prompts', + authMode: AuthMode.ApiKey, + longDescription: + 'Manage organizations, categories, topics, tracked prompts, and tags; inspect brands, AI answers, citations, and search queries; and run visibility, share-of-voice, and AI-traffic reports with the Gauge Chat API.', + docsLink: 'https://docs.sim.ai/integrations/gauge', + category: 'tools', + integrationType: IntegrationType.Marketing, + bgColor: '#E8FF3F', + icon: GaugeIcon, + canvasPresentation: { + defaultTitle: 'Gauge', + sentences: { + byOperation: { + gauge_get_current_credential: ['Read the current API key identity and permissions'], + gauge_list_organizations: ['List accessible organizations'], + gauge_get_organization: [ + { text: 'Read organization', field: ['orgSelector', 'manualOrg'], core: true }, + ], + gauge_list_categories: [ + { text: 'List categories in', field: ['orgSelector', 'manualOrg'], core: true }, + ], + gauge_list_tags: [ + { text: 'List tags in', field: ['orgSelector', 'manualOrg'], core: true }, + ], + gauge_get_category: [ + { text: 'Read category', field: ['categorySelector', 'manualCategory'], core: true }, + ], + gauge_list_topics: [ + { text: 'List topics in', field: ['categorySelector', 'manualCategory'], core: true }, + ], + gauge_list_prompts: [ + { text: 'List prompts in', field: ['categorySelector', 'manualCategory'], core: true }, + ], + gauge_list_brands: [ + { text: 'List brands in', field: ['categorySelector', 'manualCategory'], core: true }, + ], + gauge_list_answers: [ + { text: 'List answers in', field: ['categorySelector', 'manualCategory'], core: true }, + ], + gauge_list_citations: [ + { text: 'List citations in', field: ['categorySelector', 'manualCategory'], core: true }, + ], + gauge_create_topic: [{ text: 'Create topic named', field: ['title'], core: true }], + gauge_get_topic: [ + { text: 'Read topic', field: ['topicIdSelector', 'manualTopicId'], core: true }, + ], + gauge_rename_topic: [ + { text: 'Rename topic', field: ['topicIdSelector', 'manualTopicId'], core: true }, + { text: 'to', field: 'title', core: true }, + ], + gauge_create_prompts: [ + { + text: 'Create tracked prompts in', + field: ['categorySelector', 'manualCategory'], + core: true, + }, + ], + gauge_bulk_update_prompts: [ + { + text: 'Update tracked prompts in', + field: ['categorySelector', 'manualCategory'], + core: true, + }, + ], + gauge_add_tags_to_prompts: [ + { + text: 'Add tags to prompts in', + field: ['categorySelector', 'manualCategory'], + core: true, + }, + ], + gauge_remove_tags_from_prompts: [ + { + text: 'Remove tags from prompts in', + field: ['categorySelector', 'manualCategory'], + core: true, + }, + ], + gauge_get_prompt: [ + { text: 'Read prompt', field: ['promptIdSelector', 'manualPromptId'], core: true }, + ], + gauge_update_prompt: [ + { text: 'Update prompt', field: ['promptIdSelector', 'manualPromptId'], core: true }, + ], + gauge_create_tag: [{ text: 'Create tag named', field: ['name'], core: true }], + gauge_get_tag: [{ text: 'Read tag', field: ['tagIdSelector', 'manualTagId'], core: true }], + gauge_update_tag: [ + { text: 'Update tag', field: ['tagIdSelector', 'manualTagId'], core: true }, + ], + gauge_delete_tag: [ + { text: 'Delete tag', field: ['tagIdSelector', 'manualTagId'], core: true }, + ], + gauge_get_answer: [ + { text: 'Read answer', field: ['answerIdSelector', 'manualAnswerId'], core: true }, + ], + gauge_query: [ + { text: 'Run a report for', field: ['orgSelector', 'manualOrg'], core: true }, + ], + gauge_get_query_metadata: [ + { + text: 'Read the report query catalog for', + field: ['orgSelector', 'manualOrg'], + core: true, + }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Get Current Credential', id: 'gauge_get_current_credential' }, + { label: 'List Organizations', id: 'gauge_list_organizations' }, + { label: 'Get Organization', id: 'gauge_get_organization' }, + { label: 'List Categories', id: 'gauge_list_categories' }, + { label: 'Get Category', id: 'gauge_get_category' }, + { label: 'List Topics', id: 'gauge_list_topics' }, + { label: 'Create Topic', id: 'gauge_create_topic' }, + { label: 'Get Topic', id: 'gauge_get_topic' }, + { label: 'Rename Topic', id: 'gauge_rename_topic' }, + { label: 'List Prompts', id: 'gauge_list_prompts' }, + { label: 'Create Prompts', id: 'gauge_create_prompts' }, + { label: 'Bulk Update Prompts', id: 'gauge_bulk_update_prompts' }, + { label: 'Add Tags to Prompts', id: 'gauge_add_tags_to_prompts' }, + { label: 'Remove Tags from Prompts', id: 'gauge_remove_tags_from_prompts' }, + { label: 'Get Prompt', id: 'gauge_get_prompt' }, + { label: 'Update Prompt', id: 'gauge_update_prompt' }, + { label: 'List Tags', id: 'gauge_list_tags' }, + { label: 'Create Tag', id: 'gauge_create_tag' }, + { label: 'Get Tag', id: 'gauge_get_tag' }, + { label: 'Update Tag', id: 'gauge_update_tag' }, + { label: 'Delete Tag', id: 'gauge_delete_tag' }, + { label: 'List Brands', id: 'gauge_list_brands' }, + { label: 'Get Query Catalog', id: 'gauge_get_query_metadata' }, + { label: 'Run Report Query', id: 'gauge_query' }, + { label: 'List Answers', id: 'gauge_list_answers' }, + { label: 'Get Answer', id: 'gauge_get_answer' }, + { label: 'List Citations', id: 'gauge_list_citations' }, + ], + value: () => 'gauge_list_prompts', + }, + { + id: 'credential', + title: 'Gauge Account', + type: 'oauth-input', + serviceId: 'gauge', + credentialKind: 'service-account', + canonicalParamId: 'oauthCredential', + mode: 'basic', + placeholder: 'Select Gauge credential', + required: true, + }, + { + id: 'manualCredential', + title: 'Gauge Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + { + id: 'orgSelector', + title: 'Organization', + type: 'project-selector', + condition: { + field: 'operation', + value: [ + 'gauge_get_organization', + 'gauge_list_categories', + 'gauge_get_category', + 'gauge_list_topics', + 'gauge_create_topic', + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_list_prompts', + 'gauge_create_prompts', + 'gauge_bulk_update_prompts', + 'gauge_add_tags_to_prompts', + 'gauge_remove_tags_from_prompts', + 'gauge_get_prompt', + 'gauge_update_prompt', + 'gauge_list_tags', + 'gauge_create_tag', + 'gauge_get_tag', + 'gauge_update_tag', + 'gauge_delete_tag', + 'gauge_list_brands', + 'gauge_get_query_metadata', + 'gauge_query', + 'gauge_list_answers', + 'gauge_get_answer', + 'gauge_list_citations', + ], + }, + serviceId: 'gauge', + selectorKey: 'gauge.organizations', + canonicalParamId: 'org', + mode: 'basic', + placeholder: 'Select organization', + dependsOn: ['credential'], + required: true, + canvasNoun: 'an organization', + }, + { + id: 'manualOrg', + title: 'Organization ID', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'gauge_get_organization', + 'gauge_list_categories', + 'gauge_get_category', + 'gauge_list_topics', + 'gauge_create_topic', + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_list_prompts', + 'gauge_create_prompts', + 'gauge_bulk_update_prompts', + 'gauge_add_tags_to_prompts', + 'gauge_remove_tags_from_prompts', + 'gauge_get_prompt', + 'gauge_update_prompt', + 'gauge_list_tags', + 'gauge_create_tag', + 'gauge_get_tag', + 'gauge_update_tag', + 'gauge_delete_tag', + 'gauge_list_brands', + 'gauge_get_query_metadata', + 'gauge_query', + 'gauge_list_answers', + 'gauge_get_answer', + 'gauge_list_citations', + ], + }, + canonicalParamId: 'org', + mode: 'advanced', + placeholder: 'Enter organization slug or ID', + required: true, + canvasNoun: 'an organization', + }, + { + id: 'categorySelector', + title: 'Category', + type: 'project-selector', + condition: { + field: 'operation', + value: [ + 'gauge_get_category', + 'gauge_list_topics', + 'gauge_create_topic', + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_list_prompts', + 'gauge_create_prompts', + 'gauge_bulk_update_prompts', + 'gauge_add_tags_to_prompts', + 'gauge_remove_tags_from_prompts', + 'gauge_get_prompt', + 'gauge_update_prompt', + 'gauge_list_brands', + 'gauge_list_answers', + 'gauge_get_answer', + 'gauge_list_citations', + ], + }, + serviceId: 'gauge', + selectorKey: 'gauge.categories', + canonicalParamId: 'category', + mode: 'basic', + placeholder: 'Select category', + dependsOn: ['credential', 'orgSelector'], + required: true, + canvasNoun: 'a category', + }, + { + id: 'manualCategory', + title: 'Category ID', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'gauge_get_category', + 'gauge_list_topics', + 'gauge_create_topic', + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_list_prompts', + 'gauge_create_prompts', + 'gauge_bulk_update_prompts', + 'gauge_add_tags_to_prompts', + 'gauge_remove_tags_from_prompts', + 'gauge_get_prompt', + 'gauge_update_prompt', + 'gauge_list_brands', + 'gauge_list_answers', + 'gauge_get_answer', + 'gauge_list_citations', + ], + }, + canonicalParamId: 'category', + mode: 'advanced', + placeholder: 'Enter category slug or cat_ ID', + required: true, + canvasNoun: 'a category', + }, + { + id: 'topicIdSelector', + title: 'Topic', + type: 'project-selector', + condition: { + field: 'operation', + value: [ + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_bulk_update_prompts', + 'gauge_update_prompt', + ], + }, + serviceId: 'gauge', + selectorKey: 'gauge.topics', + canonicalParamId: 'topicId', + mode: 'basic', + placeholder: 'Select topic', + dependsOn: ['credential', 'orgSelector', 'categorySelector'], + required: { field: 'operation', value: ['gauge_get_topic', 'gauge_rename_topic'] }, + canvasNoun: 'a topic', + }, + { + id: 'manualTopicId', + title: 'Topic ID', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_bulk_update_prompts', + 'gauge_update_prompt', + ], + }, + canonicalParamId: 'topicId', + mode: 'advanced', + placeholder: 'Enter top_ ID; leave empty to keep unchanged when updating', + required: { field: 'operation', value: ['gauge_get_topic', 'gauge_rename_topic'] }, + canvasNoun: 'a topic', + }, + { + id: 'topicFilterSelector', + title: 'Topic Filter', + type: 'project-selector', + condition: { field: 'operation', value: ['gauge_list_prompts'] }, + serviceId: 'gauge', + selectorKey: 'gauge.topics', + canonicalParamId: 'topicFilter', + mode: 'basic', + placeholder: 'Select topic filter', + dependsOn: ['credential', 'orgSelector', 'categorySelector'], + required: false, + canvasNoun: 'a topic filter', + }, + { + id: 'manualTopicFilter', + title: 'Topic Filter ID', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_list_prompts'] }, + canonicalParamId: 'topicFilter', + mode: 'advanced', + placeholder: 'Enter topic slug or top_ ID; leave empty for all topics', + required: false, + canvasNoun: 'a topic filter', + }, + { + id: 'promptIdSelector', + title: 'Prompt', + type: 'project-selector', + condition: { field: 'operation', value: ['gauge_get_prompt', 'gauge_update_prompt'] }, + serviceId: 'gauge', + selectorKey: 'gauge.prompts', + canonicalParamId: 'promptId', + mode: 'basic', + placeholder: 'Select prompt', + dependsOn: ['credential', 'orgSelector', 'categorySelector'], + required: true, + canvasNoun: 'a prompt', + }, + { + id: 'manualPromptId', + title: 'Prompt ID', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_get_prompt', 'gauge_update_prompt'] }, + canonicalParamId: 'promptId', + mode: 'advanced', + placeholder: 'Enter prm_ ID', + required: true, + canvasNoun: 'a prompt', + }, + { + id: 'tagIdSelector', + title: 'Tag', + type: 'project-selector', + condition: { + field: 'operation', + value: ['gauge_get_tag', 'gauge_update_tag', 'gauge_delete_tag'], + }, + serviceId: 'gauge', + selectorKey: 'gauge.tags', + canonicalParamId: 'tagId', + mode: 'basic', + placeholder: 'Select tag', + dependsOn: ['credential', 'orgSelector'], + required: true, + canvasNoun: 'a tag', + }, + { + id: 'manualTagId', + title: 'Tag ID', + type: 'short-input', + condition: { + field: 'operation', + value: ['gauge_get_tag', 'gauge_update_tag', 'gauge_delete_tag'], + }, + canonicalParamId: 'tagId', + mode: 'advanced', + placeholder: 'Enter tag ID', + required: true, + canvasNoun: 'a tag', + }, + { + id: 'answerIdSelector', + title: 'Answer', + type: 'project-selector', + condition: { field: 'operation', value: ['gauge_get_answer'] }, + serviceId: 'gauge', + selectorKey: 'gauge.answers', + canonicalParamId: 'answerId', + mode: 'basic', + placeholder: 'Select answer', + dependsOn: ['credential', 'orgSelector', 'categorySelector'], + required: true, + canvasNoun: 'an answer', + }, + { + id: 'manualAnswerId', + title: 'Answer ID', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_get_answer'] }, + canonicalParamId: 'answerId', + mode: 'advanced', + placeholder: 'Enter answer ID', + required: true, + canvasNoun: 'an answer', + }, + { + id: 'title', + title: 'Topic Title', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_create_topic', 'gauge_rename_topic'] }, + placeholder: 'Enter topic title', + required: true, + }, + { + id: 'name', + title: 'Tag Name', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_create_tag', 'gauge_update_tag'] }, + placeholder: 'Enter tag name; leave empty to keep unchanged when updating', + required: { field: 'operation', value: ['gauge_create_tag'] }, + }, + { + id: 'color', + title: 'Tag Color', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_create_tag', 'gauge_update_tag'] }, + placeholder: 'Enter tag color (up to 20 characters)', + required: false, + }, + { + id: 'prompts', + title: 'Prompts', + type: 'code', + condition: { field: 'operation', value: ['gauge_create_prompts'] }, + language: 'json', + placeholder: + '[{"text":"What are the best tools for this task?","topicId":"top_...","trackingType":"visibility","tagIds":[]}]', + required: true, + wandConfig: { + enabled: true, + prompt: 'Generate prompts as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe prompts...', + generationType: 'json-object', + }, + }, + { + id: 'dryRun', + title: 'Preview Without Creating', + type: 'switch', + condition: { field: 'operation', value: ['gauge_create_prompts'] }, + required: false, + mode: 'advanced', + defaultValue: false, + }, + { + id: 'ids', + title: 'Prompt IDs to Update', + type: 'code', + condition: { field: 'operation', value: ['gauge_bulk_update_prompts'] }, + language: 'json', + placeholder: '["prm_..."] (1 to 500 IDs)', + required: true, + wandConfig: { + enabled: true, + prompt: + 'Generate prompt ids to update as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe prompt ids to update...', + generationType: 'json-object', + }, + }, + { + id: 'promptIds', + title: 'Prompt IDs', + type: 'code', + condition: { + field: 'operation', + value: ['gauge_add_tags_to_prompts', 'gauge_remove_tags_from_prompts'], + }, + language: 'json', + placeholder: '["prm_..."] (1 to 500 IDs)', + required: true, + wandConfig: { + enabled: true, + prompt: 'Generate prompt ids as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe prompt ids...', + generationType: 'json-object', + }, + }, + { + id: 'tagIds', + title: 'Tag IDs', + type: 'code', + condition: { + field: 'operation', + value: ['gauge_add_tags_to_prompts', 'gauge_remove_tags_from_prompts'], + }, + language: 'json', + placeholder: '["tag_..."] (1 to 50 IDs)', + required: true, + wandConfig: { + enabled: true, + prompt: 'Generate tag ids as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe tag ids...', + generationType: 'json-object', + }, + }, + { + id: 'promptTrackingType', + title: 'Tracking Type', + type: 'dropdown', + paramVisibility: 'user-or-llm', + condition: { + field: 'operation', + value: ['gauge_bulk_update_prompts', 'gauge_update_prompt'], + }, + options: [ + { label: 'Unchanged', id: 'unchanged' }, + { label: 'Visibility', id: 'visibility' }, + { label: 'Own Brand', id: 'branded_own' }, + ], + required: false, + value: () => 'unchanged', + }, + { + id: 'trackingType', + title: 'Tracking Type', + type: 'dropdown', + condition: { + field: 'operation', + value: ['gauge_list_prompts', 'gauge_list_answers', 'gauge_list_citations'], + }, + options: [ + { label: 'All', id: 'all' }, + { label: 'Visibility', id: 'visibility' }, + { label: 'Own Brand', id: 'branded_own' }, + ], + required: false, + mode: 'advanced', + value: () => 'all', + }, + { + id: 'since', + title: 'Start Date', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_list_answers', 'gauge_list_citations'] }, + placeholder: 'YYYY-MM-DD', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate a date in YYYY-MM-DD format. Return ONLY the date.', + placeholder: 'Describe the date...', + generationType: 'timestamp', + }, + }, + { + id: 'until', + title: 'End Date', + type: 'short-input', + condition: { field: 'operation', value: ['gauge_list_answers', 'gauge_list_citations'] }, + placeholder: 'YYYY-MM-DD', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate a date in YYYY-MM-DD format. Return ONLY the date.', + placeholder: 'Describe the date...', + generationType: 'timestamp', + }, + }, + { + id: 'model', + title: 'Models', + type: 'code', + condition: { field: 'operation', value: ['gauge_list_answers', 'gauge_list_citations'] }, + language: 'json', + placeholder: '["chatgpt"]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate models as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe models...', + generationType: 'json-object', + }, + }, + { + id: 'prompt', + title: 'Prompt Filters', + type: 'code', + condition: { field: 'operation', value: ['gauge_list_answers', 'gauge_list_citations'] }, + language: 'json', + placeholder: '["prm_..."]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate prompt filters as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe prompt filters...', + generationType: 'json-object', + }, + }, + { + id: 'topic', + title: 'Topic Filters', + type: 'code', + dependsOn: ['operation'], + condition: { field: 'operation', value: ['gauge_list_answers', 'gauge_list_citations'] }, + language: 'json', + placeholder: '["top_..."]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate topic filters as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe topic filters...', + generationType: 'json-object', + }, + }, + { + id: 'tag', + title: 'Tag Filters', + type: 'code', + condition: { + field: 'operation', + value: ['gauge_list_prompts', 'gauge_list_answers', 'gauge_list_citations'], + }, + language: 'json', + placeholder: '["tag_..."]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate tag filters as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe tag filters...', + generationType: 'json-object', + }, + }, + { + id: 'brand', + title: 'Mentioned Brands', + type: 'code', + condition: { field: 'operation', value: ['gauge_list_answers'] }, + language: 'json', + placeholder: '["brand_..."]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate mentioned brands as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe mentioned brands...', + generationType: 'json-object', + }, + }, + { + id: 'brandAbsent', + title: 'Absent Brands', + type: 'code', + condition: { field: 'operation', value: ['gauge_list_answers'] }, + language: 'json', + placeholder: '["brand_..."]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate absent brands as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe absent brands...', + generationType: 'json-object', + }, + }, + { + id: 'domain', + title: 'Domains', + type: 'code', + condition: { field: 'operation', value: ['gauge_list_citations'] }, + language: 'json', + placeholder: '["example.com"]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate domains as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe domains...', + generationType: 'json-object', + }, + }, + { + id: 'page', + title: 'Pages', + type: 'code', + condition: { field: 'operation', value: ['gauge_list_citations'] }, + language: 'json', + placeholder: '["page_..."]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate pages as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe pages...', + generationType: 'json-object', + }, + }, + { + id: 'dataset', + title: 'Dataset', + type: 'dropdown', + condition: { field: 'operation', value: ['gauge_query'] }, + options: [ + { label: 'Answers', id: 'answers' }, + { label: 'Citations', id: 'citations' }, + { label: 'Search Queries', id: 'searchQueries' }, + { label: 'AI Traffic', id: 'aiTraffic' }, + ], + required: { field: 'operation', value: ['gauge_query'] }, + value: () => 'answers', + }, + { + id: 'metadataDataset', + title: 'Dataset', + type: 'dropdown', + paramVisibility: 'user-or-llm', + condition: { field: 'operation', value: 'gauge_get_query_metadata' }, + options: [ + { label: 'All Datasets', id: 'all' }, + { label: 'Answers', id: 'answers' }, + { label: 'Citations', id: 'citations' }, + { label: 'Search Queries', id: 'searchQueries' }, + { label: 'AI Traffic', id: 'aiTraffic' }, + ], + required: false, + value: () => 'all', + }, + { + id: 'metrics', + title: 'Metrics', + type: 'code', + condition: { field: 'operation', value: ['gauge_query'] }, + language: 'json', + placeholder: '["answers"] (1 to 4; use the query catalog)', + required: true, + wandConfig: { + enabled: true, + prompt: 'Generate metrics as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe metrics...', + generationType: 'json-object', + }, + }, + { + id: 'dimensions', + title: 'Dimensions', + type: 'code', + condition: { field: 'operation', value: ['gauge_query'] }, + language: 'json', + placeholder: '["date","model"] (up to 3; use the query catalog)', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate dimensions as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe dimensions...', + generationType: 'json-object', + }, + }, + { + id: 'filters', + title: 'Report Filters', + type: 'code', + condition: { field: 'operation', value: ['gauge_query'] }, + language: 'json', + placeholder: '{"category":"category-slug","model":["chatgpt"],"tagMode":"any"}', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate report filters as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe report filters...', + generationType: 'json-object', + }, + }, + { + id: 'where', + title: 'Dimension Conditions', + type: 'code', + condition: { field: 'operation', value: ['gauge_query'] }, + language: 'json', + placeholder: '[{"dimension":"model","op":"eq","value":"chatgpt"}]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate dimension conditions as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe dimension conditions...', + generationType: 'json-object', + }, + }, + { + id: 'dateRange', + title: 'Report Date Range', + type: 'code', + condition: { field: 'operation', value: ['gauge_query'] }, + language: 'json', + placeholder: '{"since":"30d"} or {"lastDays":30}', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate report date range as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe report date range...', + generationType: 'json-object', + }, + }, + { + id: 'orderBy', + title: 'Report Sort Order', + type: 'code', + condition: { field: 'operation', value: ['gauge_query'] }, + language: 'json', + placeholder: '[{"key":"visibility","dir":"desc"}]', + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate report sort order as valid JSON for the Gauge Chat API. Return ONLY JSON.', + placeholder: 'Describe report sort order...', + generationType: 'json-object', + }, + }, + { + id: 'granularity', + title: 'Time Granularity', + type: 'dropdown', + condition: { field: 'operation', value: ['gauge_query'] }, + options: [ + { label: 'Hour', id: 'hour' }, + { label: 'Day', id: 'day' }, + { label: 'Week', id: 'week' }, + { label: 'Month', id: 'month' }, + ], + required: false, + mode: 'advanced', + value: () => 'day', + }, + { + id: 'limit', + title: 'Result Limit', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'gauge_list_topics', + 'gauge_list_prompts', + 'gauge_list_answers', + 'gauge_list_citations', + 'gauge_query', + ], + }, + placeholder: 'Defaults to 50; answers 1 to 100, other lists 1 to 200, reports 1 to 1000', + required: false, + mode: 'advanced', + }, + { + id: 'cursor', + title: 'Page Cursor', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'gauge_list_topics', + 'gauge_list_prompts', + 'gauge_list_answers', + 'gauge_list_citations', + ], + }, + placeholder: 'Enter nextCursor from the previous page', + required: false, + mode: 'advanced', + }, + ], + tools: { + access: [ + 'gauge_get_current_credential', + 'gauge_list_organizations', + 'gauge_get_organization', + 'gauge_list_categories', + 'gauge_get_category', + 'gauge_list_topics', + 'gauge_create_topic', + 'gauge_get_topic', + 'gauge_rename_topic', + 'gauge_list_prompts', + 'gauge_create_prompts', + 'gauge_bulk_update_prompts', + 'gauge_add_tags_to_prompts', + 'gauge_remove_tags_from_prompts', + 'gauge_get_prompt', + 'gauge_update_prompt', + 'gauge_list_tags', + 'gauge_create_tag', + 'gauge_get_tag', + 'gauge_update_tag', + 'gauge_delete_tag', + 'gauge_list_brands', + 'gauge_get_query_metadata', + 'gauge_query', + 'gauge_list_answers', + 'gauge_get_answer', + 'gauge_list_citations', + ], + config: { + tool: (params) => params.operation, + params: (params, context) => { + const operation = context?.operation ?? params.operation + const patch = ['gauge_bulk_update_prompts', 'gauge_update_prompt'].includes(operation) + return { + ...params, + limit: params.limit === '' || params.limit == null ? undefined : Number(params.limit), + topic: + operation === 'gauge_list_prompts' + ? (params.topicFilter ?? (context?.operation ? params.topic : undefined)) + : params.topic, + topicId: params.topicId === '' ? undefined : params.topicId, + trackingType: patch + ? params.promptTrackingType === 'unchanged' + ? undefined + : (params.promptTrackingType ?? params.trackingType) + : params.trackingType, + name: params.name === '' ? undefined : params.name, + dataset: + operation === 'gauge_get_query_metadata' + ? params.metadataDataset === 'all' + ? undefined + : (params.metadataDataset ?? params.dataset) + : params.dataset, + } + }, + }, + }, + inputs: { + oauthCredential: { type: 'string', description: 'Saved Gauge API-key credential ID' }, + org: { type: 'string', description: 'Organization slug or ID' }, + category: { type: 'string', description: 'Category slug or cat_ ID' }, + topicId: { type: 'string', description: 'Topic ID' }, + topicFilter: { type: 'string', description: 'Topic slug or ID filter' }, + promptId: { type: 'string', description: 'Prompt ID' }, + tagId: { type: 'string', description: 'Tag ID' }, + answerId: { type: 'string', description: 'Answer ID' }, + title: { type: 'string', description: 'Topic Title' }, + name: { type: 'string', description: 'Tag Name' }, + color: { type: 'string', description: 'Tag Color' }, + prompts: { type: 'json', description: 'Prompts' }, + dryRun: { type: 'boolean', description: 'Preview Without Creating' }, + ids: { type: 'json', description: 'Prompt IDs to Update' }, + promptIds: { type: 'json', description: 'Prompt IDs' }, + tagIds: { type: 'json', description: 'Tag IDs' }, + promptTrackingType: { type: 'string', description: 'Tracking Type' }, + trackingType: { type: 'string', description: 'Tracking Type' }, + since: { type: 'string', description: 'Start Date' }, + until: { type: 'string', description: 'End Date' }, + model: { type: 'json', description: 'Models' }, + prompt: { type: 'json', description: 'Prompt Filters' }, + topic: { type: 'json', description: 'Topic Filters' }, + tag: { type: 'json', description: 'Tag Filters' }, + brand: { type: 'json', description: 'Mentioned Brands' }, + brandAbsent: { type: 'json', description: 'Absent Brands' }, + domain: { type: 'json', description: 'Domains' }, + page: { type: 'json', description: 'Pages' }, + dataset: { type: 'string', description: 'Report dataset' }, + metadataDataset: { type: 'string', description: 'Dataset to inspect, or all datasets' }, + metrics: { type: 'json', description: 'Metrics' }, + dimensions: { type: 'json', description: 'Dimensions' }, + filters: { type: 'json', description: 'Report Filters' }, + where: { type: 'json', description: 'Dimension Conditions' }, + dateRange: { type: 'json', description: 'Report Date Range' }, + orderBy: { type: 'json', description: 'Report Sort Order' }, + granularity: { type: 'string', description: 'Time Granularity' }, + limit: { type: 'number', description: 'Result Limit' }, + cursor: { type: 'string', description: 'Page Cursor' }, + }, + outputs: { + user: { + type: 'json', + description: 'User identity (id, email, name), null for an organization API key', + }, + apiKey: { + type: 'json', + description: 'API key (id, name, prefix, organization {id, slug, name})', + }, + token: { type: 'json', description: 'Token permissions and expiry (kind, scopes, expiresAt)' }, + organizations: { + type: 'json', + description: 'Organizations (id, slug, name, products {chat, agents})', + }, + organization: { + type: 'json', + description: 'Organization (id, slug, name, products {chat, agents})', + }, + categories: { type: 'json', description: 'Categories (id, slug, name, description)' }, + category: { type: 'json', description: 'Category (id, slug, name, description)' }, + topics: { type: 'json', description: 'Topics (id, slug, title, promptCount, createdAt)' }, + topic: { type: 'json', description: 'Topic (id, slug, title, promptCount, createdAt)' }, + prompts: { + type: 'json', + description: 'Prompts (id, text, topicId, trackingType, tagIds, createdAt, firstAnswerDay)', + }, + prompt: { + type: 'json', + description: 'Prompt (id, text, topicId, trackingType, tagIds, createdAt, firstAnswerDay)', + }, + results: { type: 'json', description: 'Prompt creation results (index, status, prompt)' }, + dryRun: { type: 'boolean', description: 'Whether prompt creation was a preview' }, + remaining: { type: 'number', description: 'Remaining prompt allowance, nullable' }, + updated: { type: 'json', description: 'Prompt IDs successfully updated' }, + notFound: { type: 'json', description: 'Prompt IDs not found in the category' }, + promptIds: { type: 'json', description: 'Prompt IDs whose tags changed' }, + tags: { type: 'json', description: 'Tags (id, name, color, promptCount)' }, + tag: { type: 'json', description: 'Tag (id, name, color, promptCount)' }, + tagId: { type: 'string', description: 'ID of the deleted tag' }, + deleted: { type: 'boolean', description: 'Whether the tag was deleted' }, + brands: { type: 'json', description: 'Brands (id, name, slug, website, role)' }, + catalog: { + type: 'json', + description: + 'Dynamic dataset catalog of report metrics, dimensions, filters, and valid values', + }, + columns: { type: 'json', description: 'Report columns (key, kind, type, unit)' }, + rows: { + type: 'json', + description: 'Dynamic report rows keyed by selected metrics and dimensions', + }, + labels: { type: 'json', description: 'Dynamic report labels' }, + dateRange: { type: 'json', description: 'Report date range (since, until)' }, + truncated: { type: 'boolean', description: 'Whether report results were truncated' }, + cached: { type: 'boolean', description: 'Whether the report used cached results' }, + freshness: { type: 'json', description: 'Data freshness (watermark, pending, available)' }, + answers: { + type: 'json', + description: 'Answers (id, promptId, topicId, model, day, createdAt, text, brandIds)', + }, + answer: { + type: 'json', + description: + 'Answer with promptText, mentions, citations, searchQueries, model, and timestamps', + }, + citations: { + type: 'json', + description: 'Citations (answerId, pageId, promptId, model, day, domain, url)', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page, null on the last page', + }, + }, +} + +export const GaugeBlockMeta = { + tags: ['marketing', 'seo', 'data-analytics', 'automation'], + url: 'https://withgauge.com', + templates: [ + { + icon: GaugeIcon, + title: 'Gauge visibility digest', + prompt: + 'Build a weekly workflow that lists tracked brands and reads the Gauge query catalog, reports visibility and share of voice by model for the selected brand and category, compares the previous week, and posts a sourced summary to Slack. Include freshness and truncation information.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['reporting', 'seo'], + alsoIntegrations: ['slack'], + featured: true, + }, + { + icon: GaugeIcon, + title: 'Gauge prompt import', + prompt: + 'Build a workflow that reads proposed customer questions from a Sim table, lists Gauge topics, and previews creating the prompts with dryRun. After an approved run, create them in batches of at most 500 and record each created or already-existing result.', + modules: ['tables', 'workflows'], + category: 'marketing', + tags: ['automation', 'sync'], + }, + { + icon: GaugeIcon, + title: 'Gauge topic comparison', + prompt: + 'Create a scheduled workflow that lists Gauge topics, reads the query catalog, and compares AI visibility across topics using the same reporting dates and model filters. Save the report to a Sim table and flag topics that need investigation.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['analytics', 'seo'], + }, + { + icon: GaugeIcon, + title: 'Gauge citation opportunities', + prompt: + 'Build a workflow that lists Gauge citations for a selected category and date range, groups them by domain, and reads representative answers to identify owned pages cited without a brand mention and frequently cited competitor pages. Write a prioritized investigation list with source URLs.', + modules: ['agent', 'tables', 'workflows'], + category: 'marketing', + tags: ['seo', 'research'], + }, + { + icon: GaugeIcon, + title: 'Gauge brand answer audit', + prompt: + 'Build a weekly workflow that lists brands and tracked prompts in Gauge, reads paginated answers for a selected brand, and summarizes how different models describe it. Include the original prompt, answer date, mentions, and citations as evidence.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['monitoring', 'reporting'], + }, + { + icon: GaugeIcon, + title: 'Gauge prompt organization', + prompt: + 'Create a workflow that lists Gauge prompts, topics, and tags, proposes a topic and tag mapping for a campaign, and applies the approved mapping with bulk prompt updates and tag operations. Record prompt IDs reported as not found.', + modules: ['agent', 'tables', 'workflows'], + category: 'marketing', + tags: ['automation', 'organization'], + }, + { + icon: GaugeIcon, + title: 'Gauge AI traffic report', + prompt: + 'Build a scheduled workflow that reads the Gauge aiTraffic query catalog, selects supported dimensions and metrics, and reports AI crawler traffic trends for the last week. Save the rows and data freshness in a Sim table and summarize changes without treating requests as unique visitors.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['analytics', 'monitoring'], + }, + ], + skills: [ + { + name: 'report-gauge-ai-visibility', + description: + 'Compare AI visibility and share of voice across models, topics, or reporting periods.', + content: + '# Report Gauge AI Visibility\n\nUse comparable reporting windows to explain changes in AI presence.\n\n## Steps\n1. List organizations and categories to establish the requested scope.\n2. Read the query catalog to discover valid metrics, dimensions, filters, and metric requirements.\n3. List brands and filter or group by brand for visibility and share-of-voice reports. Keep the same dates and engine filters for each comparison.\n4. Inspect freshness and truncation before interpreting changes.\n5. Read representative answers when explaining a change.\n\n## Output\nA report with dates, models, metrics, and supporting answer evidence.\n\nReference: https://docs.withgauge.com/navigating/rankings-visibility', + }, + { + name: 'investigate-gauge-citations', + description: + 'Find which domains and pages AI cites and inspect the answers behind those citations.', + content: + '# Investigate Gauge Citations\n\nFind source pages worth improving or investigating.\n\n## Steps\n1. List citations for the selected category, dates, and models.\n2. Follow nextCursor until the requested scope is covered; report any incomplete collection.\n3. Group results by domain and URL, or use the citations report dataset with catalog-verified metrics.\n4. Read individual answers to distinguish a citation from a brand mention.\n5. Identify owned pages cited without a brand mention, competitor pages, and relevant third-party sources.\n\n## Output\nPrioritized source URLs with supporting prompts and answers.\n\nReference: https://docs.withgauge.com/navigating/citations', + }, + { + name: 'import-gauge-tracked-prompts', + description: + 'Preview and import customer questions into Gauge topics with per-prompt results.', + content: + '# Import Gauge Tracked Prompts\n\nAdd customer-relevant questions to the tracked prompt library.\n\n## Steps\n1. List topics in the chosen category; create a topic when requested.\n2. Prepare each prompt with text and a valid topicId, plus optional trackingType and tagIds.\n3. Preview batches of at most 500 with dryRun and inspect the remaining allowance.\n4. Create the approved prompts with a Read & write key.\n5. Preserve every created or already_exists result; new prompts are queued for sampling.\n\n## Output\nPer-prompt creation results and remaining allowance.\n\nReference: https://docs.withgauge.com/chat/api/use-the-api', + }, + { + name: 'organize-gauge-prompt-library', + description: 'Move tracked prompts between topics, change tracking types, and maintain tags.', + content: + '# Organize Gauge Prompt Library\n\nKeep tracked questions organized for meaningful comparisons.\n\n## Steps\n1. List prompts, topics, and tags, following prompt pagination.\n2. Prepare the topic, visibility or branded_own tracking type, and tag changes requested by the user.\n3. Update individual prompts or batches of at most 500 IDs.\n4. Add or remove tags using at most 50 tags per request.\n5. Report updated and notFound IDs, and read back representative prompts. Prompt text is immutable; create a new prompt to change the question.\n\n## Output\nApplied changes and any prompt IDs that could not be updated.\n\nReference: https://docs.withgauge.com/navigating/prompts', + }, + { + name: 'inspect-gauge-answer-evidence', + description: + 'Read the AI answers, mentions, citations, and searches behind a tracked prompt.', + content: + '# Inspect Gauge Answer Evidence\n\nGround a visibility finding in collected AI answers.\n\n## Steps\n1. List prompts to find the relevant question and read its tracking configuration.\n2. List answers filtered to that prompt, reporting dates, and models.\n3. Read answer details for full prompt text, mentions, citations, and search queries.\n4. Compare several answers and dates because model responses vary.\n5. Distinguish what the answers show from conclusions that would need more evidence.\n\n## Output\nA concise explanation with prompt IDs, models, answer dates, and cited source URLs.\n\nReference: https://docs.withgauge.com/navigating/prompts', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/jama.ts b/apps/sim/blocks/blocks/jama.ts new file mode 100644 index 00000000000..cf23a355606 --- /dev/null +++ b/apps/sim/blocks/blocks/jama.ts @@ -0,0 +1,911 @@ +import { ApiIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' + +export const JamaBlock: BlockConfig = { + type: 'jama', + name: 'Jama Connect', + description: 'Manage requirements, traceability, and verification in Jama Connect', + longDescription: + 'Search and manage requirements, inspect custom item types and pick lists, trace upstream and downstream relationships, collaborate with comments, compare item versions and baselines, and read test plans, cycles, and runs or update execution results. Connect with saved Jama API client credentials that retain the account owner’s permissions.', + docsLink: 'https://docs.sim.ai/integrations/jama', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#E25100', + icon: ApiIcon, + authMode: AuthMode.OAuth, + canvasPresentation: { + defaultTitle: 'Jama Connect', + sentences: { + byOperation: { + list_projects: ['List projects'], + get_project: [ + { text: 'Read project', field: ['projectSelector', 'manualProjectId'], core: true }, + ], + list_item_types: ['List item types'], + get_item_type: [ + { text: 'Read item type', field: ['itemTypeSelector', 'manualItemTypeId'], core: true }, + ], + list_items: [ + { text: 'List items in', field: ['projectSelector', 'manualProjectId'], core: true }, + ], + search_items: [ + { text: 'Search items for', field: 'query', core: true }, + { text: 'in', field: ['projectSelector', 'manualProjectId'] }, + ], + get_item: [{ text: 'Read item', field: 'itemId', core: true }], + create_item: [ + 'Create an item in', + { field: ['projectSelector', 'manualProjectId'], core: true }, + ], + update_item: [{ text: 'Update item', field: 'itemId', core: true }], + delete_item: [{ text: 'Delete item', field: 'itemId', core: true }], + list_item_children: [{ text: 'List children of', field: 'itemId', core: true }], + list_upstream_items: [{ text: 'List upstream items of', field: 'itemId', core: true }], + list_downstream_items: [{ text: 'List downstream items of', field: 'itemId', core: true }], + list_relationships: [ + { + text: 'List relationships in', + field: ['projectSelector', 'manualProjectId'], + core: true, + }, + ], + get_relationship: [{ text: 'Read relationship', field: 'relationshipId', core: true }], + create_relationship: [ + { text: 'Link', field: 'fromItemId', core: true }, + { text: 'to', field: 'toItemId', core: true }, + ], + delete_relationship: [{ text: 'Delete relationship', field: 'relationshipId', core: true }], + list_relationship_types: ['List relationship types'], + list_item_comments: [{ text: 'List comments on', field: 'itemId', core: true }], + create_comment: [{ text: 'Comment on', field: 'itemId', core: true }], + list_item_versions: [{ text: 'List versions of', field: 'itemId', core: true }], + get_item_version: [{ text: 'Read version of', field: 'itemId', core: true }], + list_filters: ['List filters'], + get_filter: [{ text: 'Read filter', field: 'filterId', core: true }], + list_filter_results: [{ text: 'List results of', field: 'filterId', core: true }], + list_baselines: [ + { text: 'List baselines in', field: ['projectSelector', 'manualProjectId'], core: true }, + ], + get_baseline: [{ text: 'Read baseline', field: 'baselineId', core: true }], + list_baseline_items: [{ text: 'List items in baseline', field: 'baselineId', core: true }], + list_test_plans: [ + { text: 'List test plans in', field: ['projectSelector', 'manualProjectId'], core: true }, + ], + get_test_plan: [{ text: 'Read test plan', field: 'testPlanId', core: true }], + list_test_cycles: [{ text: 'List test cycles in', field: 'testPlanId', core: true }], + get_test_cycle: [{ text: 'Read test cycle', field: 'testCycleId', core: true }], + list_test_runs: [{ text: 'List test runs in', field: 'testCycleId', core: true }], + get_test_run: [{ text: 'Read test run', field: 'testRunId', core: true }], + update_test_run: [{ text: 'Update test run', field: 'testRunId', core: true }], + list_releases: [ + { text: 'List releases in', field: ['projectSelector', 'manualProjectId'], core: true }, + ], + list_tags: [ + { text: 'List tags in', field: ['projectSelector', 'manualProjectId'], core: true }, + ], + list_pick_lists: ['List pick lists'], + list_pick_list_options: [{ text: 'List options of', field: 'pickListId', core: true }], + get_current_user: ['Read the connected user'], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { id: 'list_projects', label: 'List Projects' }, + { id: 'get_project', label: 'Get Project' }, + { id: 'list_item_types', label: 'List Item Types' }, + { id: 'get_item_type', label: 'Get Item Type' }, + { id: 'list_items', label: 'List Items' }, + { id: 'search_items', label: 'Search Items' }, + { id: 'get_item', label: 'Get Item' }, + { id: 'create_item', label: 'Create Item' }, + { id: 'update_item', label: 'Update Item' }, + { id: 'delete_item', label: 'Delete Item' }, + { id: 'list_item_children', label: 'List Item Children' }, + { id: 'list_upstream_items', label: 'List Upstream Items' }, + { id: 'list_downstream_items', label: 'List Downstream Items' }, + { id: 'list_relationships', label: 'List Relationships' }, + { id: 'get_relationship', label: 'Get Relationship' }, + { id: 'create_relationship', label: 'Create Relationship' }, + { id: 'delete_relationship', label: 'Delete Relationship' }, + { id: 'list_relationship_types', label: 'List Relationship Types' }, + { id: 'list_item_comments', label: 'List Item Comments' }, + { id: 'create_comment', label: 'Create Comment' }, + { id: 'list_item_versions', label: 'List Item Versions' }, + { id: 'get_item_version', label: 'Get Item Version' }, + { id: 'list_filters', label: 'List Filters' }, + { id: 'get_filter', label: 'Get Filter' }, + { id: 'list_filter_results', label: 'List Filter Results' }, + { id: 'list_baselines', label: 'List Baselines' }, + { id: 'get_baseline', label: 'Get Baseline' }, + { id: 'list_baseline_items', label: 'List Baseline Items' }, + { id: 'list_test_plans', label: 'List Test Plans' }, + { id: 'get_test_plan', label: 'Get Test Plan' }, + { id: 'list_test_cycles', label: 'List Test Cycles' }, + { id: 'get_test_cycle', label: 'Get Test Cycle' }, + { id: 'list_test_runs', label: 'List Test Runs' }, + { id: 'get_test_run', label: 'Get Test Run' }, + { id: 'update_test_run', label: 'Update Test Run' }, + { id: 'list_releases', label: 'List Releases' }, + { id: 'list_tags', label: 'List Tags' }, + { id: 'list_pick_lists', label: 'List Pick Lists' }, + { id: 'list_pick_list_options', label: 'List Pick List Options' }, + { id: 'get_current_user', label: 'Get Current User' }, + ], + value: () => 'search_items', + }, + { + id: 'credential', + title: 'Jama Account', + type: 'oauth-input', + serviceId: 'jama', + credentialKind: 'service-account', + canonicalParamId: 'oauthCredential', + mode: 'basic', + required: true, + }, + { + id: 'manualCredential', + title: 'Jama Credential ID', + type: 'short-input', + canonicalParamId: 'oauthCredential', + placeholder: 'Enter a saved Jama credential ID', + mode: 'advanced', + required: true, + }, + { + id: 'projectSelector', + title: 'Project', + type: 'project-selector', + canonicalParamId: 'projectId', + placeholder: 'Select project', + canvasNoun: 'a project', + mode: 'basic', + condition: { + field: 'operation', + value: [ + 'get_project', + 'list_item_types', + 'list_items', + 'search_items', + 'create_item', + 'list_relationships', + 'list_filters', + 'list_filter_results', + 'list_baselines', + 'list_test_plans', + 'list_releases', + 'list_tags', + ], + }, + required: { + field: 'operation', + value: [ + 'get_project', + 'list_items', + 'create_item', + 'list_relationships', + 'list_baselines', + 'list_test_plans', + 'list_releases', + 'list_tags', + ], + }, + serviceId: 'jama', + selectorKey: 'jama.projects', + dependsOn: ['credential'], + }, + { + id: 'manualProjectId', + title: 'Project ID', + type: 'short-input', + canonicalParamId: 'projectId', + placeholder: 'Enter project ID', + canvasNoun: 'a project', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'get_project', + 'list_item_types', + 'list_items', + 'search_items', + 'create_item', + 'list_relationships', + 'list_filters', + 'list_filter_results', + 'list_baselines', + 'list_test_plans', + 'list_releases', + 'list_tags', + ], + }, + required: { + field: 'operation', + value: [ + 'get_project', + 'list_items', + 'create_item', + 'list_relationships', + 'list_baselines', + 'list_test_plans', + 'list_releases', + 'list_tags', + ], + }, + }, + { + id: 'itemTypeSelector', + title: 'Item Type', + type: 'project-selector', + canonicalParamId: 'itemTypeId', + placeholder: 'Select item type', + canvasNoun: 'an item type', + mode: 'basic', + condition: { field: 'operation', value: ['get_item_type', 'search_items', 'create_item'] }, + required: { field: 'operation', value: ['get_item_type', 'create_item'] }, + serviceId: 'jama', + selectorKey: 'jama.itemTypes', + dependsOn: ['credential', 'projectSelector'], + }, + { + id: 'manualItemTypeId', + title: 'Item Type ID', + type: 'short-input', + canonicalParamId: 'itemTypeId', + placeholder: 'Enter item type ID', + canvasNoun: 'an item type', + mode: 'advanced', + condition: { field: 'operation', value: ['get_item_type', 'search_items', 'create_item'] }, + required: { field: 'operation', value: ['get_item_type', 'create_item'] }, + }, + { + id: 'rootOnly', + title: 'Root Only', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: ['list_items'] }, + required: false, + }, + { + id: 'query', + title: 'Query', + type: 'short-input', + condition: { field: 'operation', value: ['search_items'] }, + required: { field: 'operation', value: ['search_items'] }, + placeholder: 'Search requirement text', + }, + { + id: 'documentKey', + title: 'Document Key', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['search_items'] }, + required: false, + placeholder: 'Enter document key', + }, + { + id: 'modifiedSince', + title: 'Modified Since', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['search_items'] }, + required: false, + placeholder: '2026-01-01T00:00:00Z', + wandConfig: { + enabled: true, + prompt: 'Generate an ISO 8601 timestamp in UTC. Return ONLY the timestamp.', + placeholder: 'Describe the modified since', + generationType: 'timestamp', + }, + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['search_items'] }, + required: false, + placeholder: 'modifiedDate.desc', + wandConfig: { + enabled: true, + prompt: + 'Generate a Jama sort expression such as modifiedDate.desc or documentKey.asc. Return ONLY the sort expression.', + placeholder: 'Describe the sort by', + }, + }, + { + id: 'itemId', + title: 'Item ID', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'get_item', + 'update_item', + 'delete_item', + 'list_item_children', + 'list_upstream_items', + 'list_downstream_items', + 'list_item_comments', + 'create_comment', + 'list_item_versions', + 'get_item_version', + ], + }, + required: { + field: 'operation', + value: [ + 'get_item', + 'update_item', + 'delete_item', + 'list_item_children', + 'list_upstream_items', + 'list_downstream_items', + 'list_item_comments', + 'create_comment', + 'list_item_versions', + 'get_item_version', + ], + }, + placeholder: 'Enter item id', + }, + { + id: 'fields', + title: 'Fields', + type: 'code', + condition: { field: 'operation', value: ['create_item', 'update_test_run'] }, + required: { field: 'operation', value: ['create_item', 'update_test_run'] }, + placeholder: '{"name":"Requirement name","description":"Description"}', + language: 'json', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object of Jama item fields using the supplied schema. Use internal field names, not display labels. Return ONLY the JSON object.', + placeholder: 'Describe the fields', + }, + }, + { + id: 'parentItemId', + title: 'Parent Item ID', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['create_item'] }, + required: false, + placeholder: 'Enter parent item id', + }, + { + id: 'childItemTypeId', + title: 'Child Item Type ID', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['create_item'] }, + required: false, + placeholder: 'Enter child item type id', + }, + { + id: 'patch', + title: 'Patch', + type: 'code', + condition: { field: 'operation', value: ['update_item'] }, + required: { field: 'operation', value: ['update_item'] }, + placeholder: '[{"op":"replace","path":"/fields/name","value":"Updated name"}]', + language: 'json', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON Patch array for Jama using add, remove, or replace and paths such as /fields/name. Return ONLY the JSON array.', + placeholder: 'Describe the patch', + }, + }, + { + id: 'relationshipId', + title: 'Relationship ID', + type: 'short-input', + condition: { field: 'operation', value: ['get_relationship', 'delete_relationship'] }, + required: { field: 'operation', value: ['get_relationship', 'delete_relationship'] }, + placeholder: 'Enter relationship id', + }, + { + id: 'fromItemId', + title: 'From Item ID', + type: 'short-input', + condition: { field: 'operation', value: ['create_relationship'] }, + required: { field: 'operation', value: ['create_relationship'] }, + placeholder: 'Enter from item id', + }, + { + id: 'toItemId', + title: 'To Item ID', + type: 'short-input', + condition: { field: 'operation', value: ['create_relationship'] }, + required: { field: 'operation', value: ['create_relationship'] }, + placeholder: 'Enter to item id', + }, + { + id: 'relationshipTypeId', + title: 'Relationship Type ID', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['create_relationship'] }, + required: false, + placeholder: 'Enter relationship type id', + }, + { + id: 'text', + title: 'Text', + type: 'long-input', + condition: { field: 'operation', value: ['create_comment'] }, + required: { field: 'operation', value: ['create_comment'] }, + placeholder: 'Enter a comment', + }, + { + id: 'inReplyTo', + title: 'In Reply To', + type: 'short-input', + mode: 'advanced', + condition: { field: 'operation', value: ['create_comment'] }, + required: false, + placeholder: 'Enter in reply to', + }, + { + id: 'versionNumber', + title: 'Version Number', + type: 'short-input', + condition: { field: 'operation', value: ['get_item_version'] }, + required: { field: 'operation', value: ['get_item_version'] }, + placeholder: 'Enter version number', + }, + { + id: 'filterId', + title: 'Filter ID', + type: 'short-input', + condition: { field: 'operation', value: ['get_filter', 'list_filter_results'] }, + required: { field: 'operation', value: ['get_filter', 'list_filter_results'] }, + placeholder: 'Enter filter id', + }, + { + id: 'baselineId', + title: 'Baseline ID', + type: 'short-input', + condition: { field: 'operation', value: ['get_baseline', 'list_baseline_items'] }, + required: { field: 'operation', value: ['get_baseline', 'list_baseline_items'] }, + placeholder: 'Enter baseline id', + }, + { + id: 'testPlanId', + title: 'Test Plan ID', + type: 'short-input', + condition: { field: 'operation', value: ['get_test_plan', 'list_test_cycles'] }, + required: { field: 'operation', value: ['get_test_plan', 'list_test_cycles'] }, + placeholder: 'Enter test plan id', + }, + { + id: 'testCycleId', + title: 'Test Cycle ID', + type: 'short-input', + condition: { field: 'operation', value: ['get_test_cycle', 'list_test_runs'] }, + required: { field: 'operation', value: ['get_test_cycle', 'list_test_runs'] }, + placeholder: 'Enter test cycle id', + }, + { + id: 'testRunId', + title: 'Test Run ID', + type: 'short-input', + condition: { field: 'operation', value: ['get_test_run', 'update_test_run'] }, + required: { field: 'operation', value: ['get_test_run', 'update_test_run'] }, + placeholder: 'Enter test run id', + }, + { + id: 'pickListId', + title: 'Pick List ID', + type: 'short-input', + condition: { field: 'operation', value: ['list_pick_list_options'] }, + required: { field: 'operation', value: ['list_pick_list_options'] }, + placeholder: 'Enter pick list id', + }, + { + id: 'startAt', + title: 'Start Offset', + type: 'short-input', + placeholder: 'Defaults to 0', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'list_item_types', + 'list_items', + 'search_items', + 'list_item_children', + 'list_upstream_items', + 'list_downstream_items', + 'list_relationships', + 'list_relationship_types', + 'list_item_comments', + 'list_item_versions', + 'list_filters', + 'list_filter_results', + 'list_baselines', + 'list_baseline_items', + 'list_test_plans', + 'list_test_cycles', + 'list_test_runs', + 'list_releases', + 'list_tags', + 'list_pick_lists', + 'list_pick_list_options', + ], + }, + }, + { + id: 'maxResults', + title: 'Page Size', + type: 'short-input', + placeholder: '1 to 50; defaults to 20', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'list_item_types', + 'list_items', + 'search_items', + 'list_item_children', + 'list_upstream_items', + 'list_downstream_items', + 'list_relationships', + 'list_relationship_types', + 'list_item_comments', + 'list_item_versions', + 'list_filters', + 'list_filter_results', + 'list_baselines', + 'list_baseline_items', + 'list_test_plans', + 'list_test_cycles', + 'list_test_runs', + 'list_releases', + 'list_tags', + 'list_pick_lists', + 'list_pick_list_options', + ], + }, + }, + ], + tools: { + access: [ + 'jama_list_projects', + 'jama_get_project', + 'jama_list_item_types', + 'jama_get_item_type', + 'jama_list_items', + 'jama_search_items', + 'jama_get_item', + 'jama_create_item', + 'jama_update_item', + 'jama_delete_item', + 'jama_list_item_children', + 'jama_list_upstream_items', + 'jama_list_downstream_items', + 'jama_list_relationships', + 'jama_get_relationship', + 'jama_create_relationship', + 'jama_delete_relationship', + 'jama_list_relationship_types', + 'jama_list_item_comments', + 'jama_create_comment', + 'jama_list_item_versions', + 'jama_get_item_version', + 'jama_list_filters', + 'jama_get_filter', + 'jama_list_filter_results', + 'jama_list_baselines', + 'jama_get_baseline', + 'jama_list_baseline_items', + 'jama_list_test_plans', + 'jama_get_test_plan', + 'jama_list_test_cycles', + 'jama_get_test_cycle', + 'jama_list_test_runs', + 'jama_get_test_run', + 'jama_update_test_run', + 'jama_list_releases', + 'jama_list_tags', + 'jama_list_pick_lists', + 'jama_list_pick_list_options', + 'jama_get_current_user', + ], + config: { + tool: (params) => `jama_${params.operation}`, + params: (params) => ({ + ...params, + startAt: + params.startAt === '' || params.startAt == null ? undefined : Number(params.startAt), + maxResults: + params.maxResults === '' || params.maxResults == null + ? undefined + : Number(params.maxResults), + rootOnly: params.rootOnly === true || params.rootOnly === 'true' ? true : undefined, + }), + }, + }, + inputs: { + operation: { type: 'string', description: 'Jama operation to run' }, + oauthCredential: { type: 'string', description: 'Saved Jama API credential ID' }, + projectId: { type: 'string', description: 'project ID' }, + itemTypeId: { type: 'string', description: 'itemType ID' }, + rootOnly: { type: 'boolean', description: 'Only root-level items in the project' }, + query: { type: 'string', description: 'Text to find in item names, descriptions, and fields' }, + documentKey: { type: 'string', description: 'Exact document key' }, + modifiedSince: { type: 'string', description: 'Only items modified since this ISO 8601 date' }, + sortBy: { + type: 'string', + description: 'Sort field and direction, for example modifiedDate.desc', + }, + itemId: { type: 'string', description: 'item ID' }, + fields: { + type: 'json', + description: 'Jama field names and values, including name and required custom fields', + }, + parentItemId: { + type: 'string', + description: 'Parent item ID; otherwise create at the project root', + }, + childItemTypeId: { + type: 'string', + description: 'Child item type ID, required for sets and folders', + }, + patch: { + type: 'json', + description: + 'JSON Patch array using add, remove, or replace, for example [{"op":"replace","path":"/fields/name","value":"Updated name"}]', + }, + relationshipId: { type: 'string', description: 'relationship ID' }, + fromItemId: { type: 'string', description: 'fromItem ID' }, + toItemId: { type: 'string', description: 'toItem ID' }, + relationshipTypeId: { + type: 'string', + description: 'Relationship type ID; defaults to the instance default', + }, + text: { type: 'string', description: 'Comment text' }, + inReplyTo: { type: 'string', description: 'Parent comment ID when replying' }, + versionNumber: { type: 'string', description: 'Version number returned by List Item Versions' }, + filterId: { type: 'string', description: 'filter ID' }, + baselineId: { type: 'string', description: 'baseline ID' }, + testPlanId: { type: 'string', description: 'testPlan ID' }, + testCycleId: { type: 'string', description: 'testCycle ID' }, + testRunId: { type: 'string', description: 'testRun ID' }, + pickListId: { type: 'string', description: 'pickList ID' }, + startAt: { type: 'number', description: 'Zero-based result offset' }, + maxResults: { type: 'number', description: 'Page size from 1 to 50' }, + }, + outputs: { + projects: { + type: 'json', + description: + 'List Projects result (id, name, projectKey, description, isFolder, createdDate, modifiedDate)', + }, + project: { + type: 'json', + description: + 'Get Project result (id, name, projectKey, description, isFolder, createdDate, modifiedDate)', + }, + itemTypes: { + type: 'json', + description: + 'List Item Types result (id, typeKey, display, displayPlural, description, category, fields)', + }, + itemType: { + type: 'json', + description: + 'Get Item Type result (id, typeKey, display, displayPlural, description, category, fields)', + }, + items: { + type: 'json', + description: + 'Item list and search results (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + item: { + type: 'json', + description: + 'Get Item or Get Item Version result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + id: { type: 'number', description: 'Created resource ID' }, + location: { type: 'string', description: 'Created resource API URL' }, + status: { type: 'number', description: 'Successful HTTP status code' }, + relationships: { + type: 'json', + description: 'List Relationships result (id, fromItem, toItem, relationshipType, suspect)', + }, + relationship: { + type: 'json', + description: 'Get Relationship result (id, fromItem, toItem, relationshipType, suspect)', + }, + relationshipTypes: { + type: 'json', + description: 'List Relationship Types result (id, name, description)', + }, + comments: { + type: 'json', + description: + 'List Item Comments result (id, inReplyTo, createdDate, createdBy, status, text, item, project)', + }, + versions: { + type: 'json', + description: + 'List Item Versions result (item, versionNumber, changeDetails, comment, createdDate, createdBy, userName)', + }, + filters: { + type: 'json', + description: 'List Filters result (id, name, author, projectScope, specifiedProject, public)', + }, + filter: { + type: 'json', + description: 'Get Filter result (id, name, author, projectScope, specifiedProject, public)', + }, + baselines: { + type: 'json', + description: + 'List Baselines result (id, baselineKey, name, description, createdDate, createdBy, project, sourceName, signed)', + }, + baseline: { + type: 'json', + description: + 'Get Baseline result (id, baselineKey, name, description, createdDate, createdBy, project, sourceName, signed)', + }, + testPlans: { + type: 'json', + description: + 'List Test Plans result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + testPlan: { + type: 'json', + description: + 'Get Test Plan result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + testCycles: { + type: 'json', + description: + 'List Test Cycles result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + testCycle: { + type: 'json', + description: + 'Get Test Cycle result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + testRuns: { + type: 'json', + description: + 'List Test Runs result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + testRun: { + type: 'json', + description: + 'Get Test Run result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)', + }, + releases: { + type: 'json', + description: + 'List Releases result (id, name, description, project, releaseDate, active, archived, itemCount)', + }, + tags: { type: 'json', description: 'List Tags result (id, name, project)' }, + pickLists: { type: 'json', description: 'List Pick Lists result (id, name, description)' }, + options: { + type: 'json', + description: 'List Pick List Options result (id, name, description, value, active, pickList)', + }, + user: { + type: 'json', + description: + 'Get Current User result (id, username, firstName, lastName, email, active, licenseType)', + }, + pageInfo: { type: 'json', description: 'Pagination (startIndex, resultCount, totalResults)' }, + }, +} + +export const JamaBlockMeta = { + tags: ['project-management', 'knowledge-base'], + url: 'https://www.jamasoftware.com/', + templates: [ + { + icon: ApiIcon, + title: 'Requirement change digest', + prompt: + 'Build a scheduled workflow that searches Jama items modified since the previous day, reads their current descriptions, and produces a digest grouped by project with document keys.', + modules: ['workflows', 'scheduled'], + category: 'engineering', + tags: ['requirements', 'reporting'], + }, + { + icon: ApiIcon, + title: 'Change impact analysis', + prompt: + 'Build an agent that takes a Jama requirement ID, reads the item and its upstream and downstream related items, and explains the potential impact of a proposed change with the affected document keys.', + modules: ['agent'], + category: 'engineering', + tags: ['traceability', 'requirements'], + }, + { + icon: ApiIcon, + title: 'Verification status report', + prompt: + 'Build a workflow that lists the test cycles in a selected Jama test plan, reads their test runs, and summarizes the recorded execution results and unresolved tests.', + modules: ['workflows'], + category: 'engineering', + tags: ['testing', 'reporting'], + }, + { + icon: ApiIcon, + title: 'Baseline comparison', + prompt: + 'Build an agent that reads the items in a selected Jama baseline, compares selected requirements against their current versions, and reports differences in the recorded fields.', + modules: ['agent'], + category: 'engineering', + tags: ['baselines', 'change-management'], + }, + { + icon: ApiIcon, + title: 'Requirement discussion summary', + prompt: + 'Build a workflow that reads a Jama requirement and its comments and summarizes the open questions, decisions, and action items without changing the requirement.', + modules: ['workflows'], + category: 'productivity', + tags: ['collaboration', 'requirements'], + }, + { + icon: ApiIcon, + title: 'Requirement creation assistant', + prompt: + 'Build an agent that discovers the item types and required fields in a selected Jama project, drafts a new requirement using that schema, and creates the item after the user confirms the content.', + modules: ['agent'], + category: 'engineering', + tags: ['requirements', 'automation'], + }, + { + icon: ApiIcon, + title: 'Requirement history review', + prompt: + 'Build an agent that lists the versions of a Jama item, reads the selected historical snapshots, and produces a timeline of changes with authors and version numbers.', + modules: ['agent'], + category: 'engineering', + tags: ['audit', 'version-history'], + }, + ], + skills: [ + { + name: 'find-jama-requirements', + description: 'Find requirements by text or document key and summarize their current content.', + content: + '# Find Jama Requirements\n\n## Steps\n1. Search items with a focused query and optional project or item type.\n2. Read the selected requirements by ID.\n3. Distinguish requirements from test records using the item type.\n\n## Output\nReturn document keys, names, and the fields relevant to the question. State when the result set is only one page.', + }, + { + name: 'assess-jama-change-impact', + description: 'Inspect the requirements and tests related to a proposed requirement change.', + content: + '# Assess Jama Change Impact\n\n## Steps\n1. Read the target requirement.\n2. List its upstream and downstream related items.\n3. Inspect the relevant linked items and summarize the potential effect of the proposed change.\n\n## Output\nSeparate observed relationships from inferred impact. Include item IDs and document keys. Do not change requirements or links without confirmation.', + }, + { + name: 'summarize-jama-verification', + description: 'Summarize recorded test execution results for a test plan.', + content: + '# Summarize Jama Verification\n\n## Steps\n1. Read the selected test plan and list its test cycles.\n2. List the test runs for the relevant cycle, paging through the requested scope.\n3. Read the item type schema to interpret custom execution fields.\n\n## Output\nReport the recorded results and unresolved tests. Do not infer regulatory compliance or test success from missing results.', + }, + { + name: 'compare-jama-baseline', + description: 'Compare selected baselined requirements with their current item content.', + content: + '# Compare a Jama Baseline\n\n## Steps\n1. Read the baseline and its versioned items.\n2. Read current items for the requirements in scope.\n3. Compare their fields and version numbers.\n\n## Output\nReport the observed differences, baseline ID, and document keys. Keep missing or inaccessible current items separate from confirmed deletions.', + }, + { + name: 'review-jama-item-history', + description: 'Explain how a requirement changed across its saved versions.', + content: + '# Review Jama Item History\n\n## Steps\n1. List the item versions.\n2. Read the historical snapshots needed for the question.\n3. Compare the requested fields and associate changes with the version metadata.\n\n## Output\nProvide a timeline with version numbers, recorded authors, and dates. Separate recorded change comments from your interpretation.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 8e25f6ce584..e27f9398c23 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -95,8 +95,10 @@ import { } from '@/blocks/blocks/fireflies' import { FivetranBlock, FivetranBlockMeta } from '@/blocks/blocks/fivetran' import { FlintBlock, FlintBlockMeta } from '@/blocks/blocks/flint' +import { FlowBlock, FlowBlockMeta } from '@/blocks/blocks/flow' import { FunctionBlock } from '@/blocks/blocks/function' import { GammaBlock, GammaBlockMeta } from '@/blocks/blocks/gamma' +import { GaugeBlock, GaugeBlockMeta } from '@/blocks/blocks/gauge' import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook' import { GitHubBlock, @@ -176,6 +178,7 @@ import { IntercomV2BlockMeta, } from '@/blocks/blocks/intercom' import { IntuneBlock, IntuneBlockMeta } from '@/blocks/blocks/intune' +import { JamaBlock, JamaBlockMeta } from '@/blocks/blocks/jama' import { JinaBlock, JinaBlockMeta } from '@/blocks/blocks/jina' import { JiraBlock, JiraBlockMeta } from '@/blocks/blocks/jira' import { @@ -506,8 +509,10 @@ export const BLOCK_REGISTRY: Record = { fireflies_v2: FirefliesV2Block, fivetran: FivetranBlock, flint: FlintBlock, + flow: FlowBlock, function: FunctionBlock, gamma: GammaBlock, + gauge: GaugeBlock, generic_webhook: GenericWebhookBlock, github: GitHubBlock, github_v2: GitHubV2Block, @@ -567,6 +572,7 @@ export const BLOCK_REGISTRY: Record = { intercom: IntercomBlock, intercom_v2: IntercomV2Block, intune: IntuneBlock, + jama: JamaBlock, jina: JinaBlock, jira: JiraBlock, jira_service_management: JiraServiceManagementBlock, @@ -868,7 +874,9 @@ export const BLOCK_META_REGISTRY: Record = { fireflies_v2: FirefliesV2BlockMeta, fivetran: FivetranBlockMeta, flint: FlintBlockMeta, + flow: FlowBlockMeta, gamma: GammaBlockMeta, + gauge: GaugeBlockMeta, github: GitHubBlockMeta, github_v2: GitHubV2BlockMeta, gitlab: GitLabBlockMeta, @@ -921,6 +929,7 @@ export const BLOCK_META_REGISTRY: Record = { intercom: IntercomBlockMeta, intercom_v2: IntercomV2BlockMeta, intune: IntuneBlockMeta, + jama: JamaBlockMeta, jina: JinaBlockMeta, jira: JiraBlockMeta, jira_service_management: JiraServiceManagementBlockMeta, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 3ebc0c65a04..3ec8bb38c2b 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -183,6 +183,7 @@ export type GenerationType = | 'json-schema' | 'json-object' | 'json-array' + | 'json-value' | 'table-schema' | 'system-prompt' | 'custom-tool-schema' @@ -596,6 +597,12 @@ export type CanvasSentenceClause = /** An ordered set of clauses forming one card summary sentence. */ export type CanvasSentence = readonly CanvasSentenceClause[] +/** Trusted mapper context kept separate from user-configured and model-supplied arguments. */ +export interface BlockToolParamsContext { + /** Operation selected by the configuring surface. */ + operation?: string +} + export interface BlockConfig { type: string name: string @@ -668,7 +675,10 @@ export interface BlockConfig { access: string[] config?: { tool: (params: Record) => string - params?: (params: Record) => Record + params?: ( + params: Record, + context?: BlockToolParamsContext + ) => Record } } inputs: Record diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index cc4d3c332de..0165a855525 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -14,6 +14,19 @@ export function LucidIcon(props: LucidIconProps) { ) } +interface GaugeIconProps extends SVGProps {} + +export function GaugeIcon(props: GaugeIconProps) { + return ( + + + + ) +} + export function EnrichmentIcon(props: SVGProps) { return ( {} + +export function ThinkingMachinesIcon(props: ThinkingMachinesIconProps) { + return ( + + + + + ) +} diff --git a/apps/sim/content/library/ai-agent-builders-claude-gemini-gpt-open-models/index.mdx b/apps/sim/content/library/ai-agent-builders-claude-gemini-gpt-open-models/index.mdx new file mode 100644 index 00000000000..53fd8a47b5b --- /dev/null +++ b/apps/sim/content/library/ai-agent-builders-claude-gemini-gpt-open-models/index.mdx @@ -0,0 +1,161 @@ +--- +slug: ai-agent-builders-claude-gemini-gpt-open-models +title: 'AI Agent Builders for Claude, Gemini, GPT, and Open Models Compared' +seoTitle: 'AI Agent Builders for Claude, Gemini, GPT' +description: 'Compare AI agent builders for Claude, Gemini, GPT, DeepSeek, and open models by access path, BYOK, self-hosting, and model switching.' +date: 2026-11-06 +updated: 2026-11-06 +authors: + - andrew +readingTime: 7 +tags: [AI Agents, AI Models, Open Source, Sim] +ogImage: /library/ai-agent-builders-claude-gemini-gpt-open-models/cover.jpg +draft: false +faq: + - q: "best ai agent builder that works with claude models" + a: "Sim fits teams that want a visual Claude agent builder with Gemini, GPT, and open-model flexibility. LangGraph fits code-first state control, while n8n fits cases where Claude is one step inside a broad automation workflow." + - q: "which ai agent builder supports gemini models" + a: "Sim, Google Gemini Enterprise Agent Platform, n8n, Dify, Langflow, and LangGraph all provide documented paths to Gemini models. Google offers the most Gemini-native choice, while Sim fits visual switching among Gemini, Claude, GPT, and open models." + - q: "ai agent platform that works with deepseek or other newer models" + a: "Sim supports newer and open models through provider integrations and documented local or compatible endpoints on self-hosted deployments. Dify, Langflow, and LangGraph also fit cases where plugin, component, or code-level extensibility matters more than a unified visual workspace." + - q: "best ai agent platform for building agents on gpt models" + a: "Sim fits teams seeking a model-neutral visual workspace for GPT agents, while Microsoft Copilot Studio fits teams committed to Microsoft's cloud and governance ecosystem. Best AI Agent Platforms and Builders in 2026 compares the broader category." + - q: "Can Sim use Claude and Gemini in the same workflow?" + a: "Sim can use Claude and Gemini in different Agent blocks within the same workflow. Teams can assign each model a separate task or compare their outputs against the same test inputs." + - q: "Can Sim switch an agent from Claude to Gemini?" + a: "Sim can change an Agent block from a Claude model to a Gemini model without reconstructing the surrounding workflow. The workflow should be retested because model behavior, parameters, tool use, and structured outputs can differ." + - q: "Does Sim support bring your own API keys?" + a: "Sim supports workspace BYOK keys on every Sim Cloud plan as of November 2026. Organization-level keys require Pro for Teams, Max for Teams, or Enterprise, as explained in BYOK Multi-Model AI Agent Builder: How Sim's Bring-Your-Own-Key Works." + - q: "Can Sim run local models?" + a: "Self-hosted Sim can connect to Ollama, vLLM, LM Studio, and LiteLLM without requiring Sim Enterprise. Local models are a self-hosting capability and are not offered as user-connected local endpoints on Sim Cloud." + - q: "Is Sim open source?" + a: "Sim's core is open source under the OSI-approved Apache 2.0 license. Code in apps/sim/ee is governed by the separate Sim Enterprise License, and production use of those enterprise features requires an active Sim Enterprise subscription." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license. Its license permits many internal uses but restricts some commercial hosting and redistribution scenarios." + - q: "Does n8n support Claude and Gemini?" + a: "n8n supports Claude through its Anthropic Chat Model node and Gemini through its Google Gemini Chat Model node as of November 2026. n8n is especially useful when those models need to participate in a broader automation containing SaaS applications and operational steps." + - q: "Does Dify support Claude, Gemini, and GPT?" + a: "Dify supports configurable model providers through its plugin system as of November 2026. Dify is particularly well suited to LLM applications that combine prompts, retrieval, logs, and application interfaces." + - q: "Does Langflow support Claude, Gemini, and open models?" + a: "Langflow supports Claude, Gemini, GPT, and local or open-model paths through visual components as of November 2026. Langflow fits teams that want a visual graph with Python-oriented extensibility." + - q: "Does LangGraph support Claude and Gemini?" + a: "LangGraph can use Claude and Gemini through LangChain provider packages or direct provider SDKs. LangGraph is a code-first orchestration library rather than a hosted model catalog, so developers control the integrations and deployment." + - q: "Does Google Gemini Enterprise support Claude or GPT models?" + a: "Google Gemini Enterprise Agent Platform offers Google, partner, and open-weight model paths, including selected Claude models. Teams should check its current model catalog and access conditions for the exact models they need." + - q: "Does Microsoft Copilot Studio support Gemini or Claude?" + a: "Microsoft Copilot Studio documents external primary models and Azure AI Foundry model access, including selected non-Microsoft models. Availability and data-handling conditions vary by model and feature status." + - q: "What is the difference between model support and LLM orchestration?" + a: "Model support determines which models a builder can call, while LLM orchestration determines how an application routes, chains, retries, and evaluates calls across providers. Best LLM Orchestration Tools for Multi-Provider Call Chaining in 2026 covers the orchestration category separately." + - q: "Which AI agent builder is best for self-hosted open models?" + a: "Sim fits teams that want visual, self-hosted agents connected to Ollama, vLLM, LM Studio, or LiteLLM. LangGraph offers more code-level control, while Dify and Langflow provide alternative visual approaches." + - q: "Which AI agent builder gives the clearest model cost visibility?" + a: "Sim gives teams run-level model usage and cost information while allowing provider-owned keys. Exact billing evidence still depends on whether the model is accessed through a Sim-hosted key, a customer-owned provider key, or local infrastructure." + - q: "What is the best open-source Zapier alternative for AI agents?" + a: "Sim fits teams prioritizing visual AI agents, multi-model flexibility, and an open-source core. Sim's core is Apache 2.0, while its enterprise directory uses the separate Sim Enterprise License." + - q: "What is the best n8n alternative for multi-model AI agents?" + a: "Sim fits teams that prioritize a model-neutral visual AI workspace and an Apache 2.0 core. Teams should compare that fit with n8n's broad automation focus and source-available license." + - q: "Sim vs n8n: which is better for Claude and Gemini agents?" + a: "Sim fits teams whose primary goal is building and managing model-neutral AI agents, while n8n fits cases where AI calls are embedded in broad SaaS automation. Sim has an Apache 2.0 core, whereas n8n uses the source-available Sustainable Use License." + - q: "Sim vs Gumloop: which is better for multi-model agents?" + a: "Sim fits teams that prioritize an open-source core, self-hosting, BYOK, and local-model connections. Gumloop may fit teams seeking a managed automation experience, but buyers should check current model, pricing, and deployment details in Gumloop's documentation." + - q: "Is Sim free?" + a: "Sim's Apache 2.0 core can be self-hosted without a Sim Enterprise subscription. Cloud usage, hosted model calls, and production use of apps/sim/ee features are governed by their applicable plans, provider costs, and the Sim Enterprise License as of November 2026." +--- + +## TL;DR + +Sim fits teams that want to use Claude, Gemini, GPT, DeepSeek, and self-hosted open models in the same visual AI workspace. Google Gemini Enterprise Agent Platform fits Google Cloud-centered deployments, Microsoft Copilot Studio fits Microsoft-governed deployments, and n8n, Dify, Langflow, and LangGraph address different automation, low-code, and code-first requirements. + +The right choice depends on how each model is reached, who owns the provider key, where the agent runs, what usage evidence is available, and how quickly a new model can be added. + +## Which AI agent builder supports Claude, Gemini, GPT, DeepSeek, and open models? + +Sim, n8n, Dify, Langflow, and LangGraph all provide routes to commercial and open model families. Google Gemini Enterprise Agent Platform and Microsoft Copilot Studio also expose multiple model paths, but their identity, governance, and deployment models remain closely tied to their cloud ecosystems. + +This comparison distinguishes direct selection from access through provider plugins, packages, cloud catalogs, and compatible endpoints. Documentation was reviewed as of November 2026, and model catalogs change frequently. + +| AI agent builder | Documented model path | How new models arrive | Key and deployment fit | +|---|---|---|---| +| **Sim** | The [Sim model directory](https://www.sim.ai/models) covers Anthropic, Google, OpenAI, and other hosted providers. [Self-hosting documentation](https://docs.sim.ai/platform/self-hosting) covers Ollama, while the application also supports configured vLLM, LM Studio, and LiteLLM endpoints. | Provider updates or a compatible configured endpoint | [Workspace BYOK works on every Sim Cloud plan](https://docs.sim.ai/platform/costs), while local model connections run on self-hosted deployments. | +| **n8n** | Dedicated nodes document [Claude](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatanthropic), [Gemini](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatgooglegemini), and [DeepSeek](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatdeepseek). | Built-in or community node updates, or API calls | Provider credentials work with cloud or self-hosted n8n; self-hosted code uses the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license). | +| **Dify** | [Model providers](https://docs.dify.ai/en/cloud/use-dify/workspace/model-providers) can use Dify credits or customer provider accounts, and the [self-hosted LLM node](https://docs.dify.ai/en/self-host/use-dify/nodes/llm) documents local providers. | Marketplace or provider-plugin updates | Fits plugin-based model configuration and self-hosting under the [Dify license](https://github.com/langgenius/dify/blob/main/LICENSE). | +| **Langflow** | The [Language Model component](https://docs.langflow.org/components-models) supports multiple providers, with documented bundles for Anthropic, Google, OpenAI, Azure, and Ollama. | Component or Python package updates | Fits visual graphs with provider keys and self-hosting under the [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE). | +| **LangGraph** | [LangChain provider integrations](https://docs.langchain.com/oss/python/integrations/providers/overview) cover Anthropic, Google, OpenAI, Ollama, and other providers used by LangGraph applications. | Dependency or application-code updates | Fits code-controlled credentials and deployment; the library uses the [MIT License](https://github.com/langchain-ai/langgraph/blob/main/LICENSE). | +| **Google Gemini Enterprise Agent Platform** | Its [model overview](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models) covers Google, partner, and open-weight models, while partner documentation lists selected [Claude models](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/use-partner-models). | Google-managed catalog updates | Fits Google Cloud projects, authentication, billing, and managed infrastructure. | +| **Microsoft Copilot Studio** | Microsoft documents selectable [primary models](https://learn.microsoft.com/en-us/microsoft-copilot-studio/authoring-select-agent-model), [external response models](https://learn.microsoft.com/en-us/microsoft-copilot-studio/authoring-select-external-response-model), and models reached through [Azure AI Foundry](https://learn.microsoft.com/en-us/microsoft-copilot-studio/bring-your-own-model-prompts). | Microsoft catalog and feature updates | Fits Microsoft-managed identity, connections, governance, and usage billing. | + +## What should buyers compare beyond a model-support checkbox? + +A model family in a catalog does not establish that every model, region, or account has the same access. Compare the operational path: + +- **Direct versus indirect access:** A native provider integration is generally simpler to configure than a generic request or custom connector. +- **Model and region availability:** Confirm the exact model in the deployment region and account before committing an architecture. +- **Key ownership:** BYOK can preserve a team's provider account, limits, data terms, and negotiated pricing. +- **Local-model support:** Ollama, vLLM, LM Studio, and LiteLLM matter when inference must stay in a team-controlled environment. +- **Switching cost:** Check whether tools, routing, and downstream steps remain intact when the selected model changes. +- **Usage evidence:** Token counts, model identifiers, latency, and cost data make tests easier to audit. +- **Arrival path:** A new model may require a vendor release, plugin update, dependency upgrade, catalog addition, or compatible endpoint. + +For a broader evaluation process, use the [AI workflow automation buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) alongside the model comparison. + +## Which builder fits Claude agents? + +Sim fits teams that want Claude in a visual workflow without making Anthropic the workspace's only provider. An Agent block can use an Anthropic model, call tools, and pass results to later workflow steps. The same workflow can then be tested with Gemini or GPT. + +n8n fits cases where Claude participates in broader SaaS automation. LangGraph fits developers who want code-level control over state, retries, and model calls. Dify fits LLM applications centered on prompts, retrieval, and provider plugins, while Langflow offers a visual graph with Python-oriented components. + +## Which builder fits Gemini agents? + +Google Gemini Enterprise Agent Platform fits organizations that already standardize on Google Cloud identity, data, and managed AI services. Sim fits teams that want Gemini in a visual agent while retaining direct routes to Claude, GPT, and self-hosted models. + +[n8n](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatgooglegemini), [Dify](https://docs.dify.ai/en/cloud/use-dify/workspace/model-providers), [Langflow](https://docs.langflow.org/bundles-google), and [LangGraph through LangChain](https://docs.langchain.com/oss/python/integrations/providers/google) also have documented Google model paths. Their building models differ: n8n emphasizes automation nodes, Dify emphasizes LLM applications, Langflow emphasizes composable visual components, and LangGraph emphasizes code-defined state. + +## Which builder fits GPT agents? + +Sim fits teams seeking a model-neutral visual environment for GPT agents. Microsoft Copilot Studio fits organizations whose architecture is determined by Microsoft 365, Power Platform, Azure, and Microsoft governance. + +LangGraph fits code-first applications that need explicit state and application-level control. n8n fits cases where a GPT call is one step in a larger operational automation. + +## Which builder works with DeepSeek or newer open models? + +Self-hosted Sim can connect to Ollama and configured vLLM, LM Studio, or LiteLLM endpoints. This provides a route to models served by those runtimes without requiring Sim Enterprise. + +[Dify](https://docs.dify.ai/en/self-host/use-dify/nodes/llm) and [Langflow](https://docs.langflow.org/components-models) offer plugin or component paths for local models. [LangGraph applications can use LangChain provider packages](https://docs.langchain.com/oss/python/integrations/providers/overview) or custom model clients, with a corresponding increase in engineering responsibility. + +The [self-hosted AI platforms comparison](https://www.sim.ai/library/best-self-hosted-ai-workflow-automation-platforms-2026) covers deployment boundaries in more detail. + +## How can an agent switch from Claude to Gemini without being rebuilt? + +In Sim, a builder can change an existing Agent block from an Anthropic model to a Google model while preserving the surrounding workflow, connected tools, inputs, and downstream steps. Model-specific parameters may still need to be remapped. + +For example, a support-triage workflow can send a ticket to an Agent block, retrieve account context, and route a structured result to Slack. A builder can change the provider and model in that Agent block and rerun the same test cases without reconstructing the workflow. + +Retesting remains essential because models can differ in tool selection, structured-output reliability, latency, context handling, and response style. + +## How should teams mix Claude, Gemini, and GPT? + +Sim supports different models in different Agent blocks, so a team can use one model for classification, another for analysis, and another for response generation. Teams can also run comparable inputs through multiple models before choosing a production path. + +The [BYOK multi-model guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) explains key ownership in Sim. The [LLM orchestration tools comparison](https://www.sim.ai/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026) covers routing and multi-provider call chaining as a broader engineering category. + +## Which platform fits each type of team? + +- **Choose Sim** when the requirement is a visual, model-neutral workspace with BYOK and local-model access on self-hosted deployments. +- **Choose n8n** when model calls must sit inside broad SaaS and operational automation. +- **Choose Dify** when the primary job is assembling LLM applications with prompts, retrieval, model plugins, and application interfaces. +- **Choose Langflow** when both a visual graph and Python-extensible components matter. +- **Choose LangGraph** when developers want code-first control over stateful agent execution. +- **Choose Google Gemini Enterprise Agent Platform** when Google Cloud identity, infrastructure, and model services determine the architecture. +- **Choose Microsoft Copilot Studio** when Microsoft 365, Power Platform, Azure, and centralized Microsoft governance determine the architecture. + +## What are the key license and deployment facts? + +- **Sim:** Sim's core is Apache 2.0 and self-hostable. Code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. +- **n8n:** n8n's self-hosted community code is source-available under its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) rather than an OSI-approved open-source license. +- **Dify:** Dify can be self-hosted under a [modified Apache 2.0 license with additional conditions](https://github.com/langgenius/dify/blob/main/LICENSE). +- **Langflow:** Langflow is [MIT-licensed](https://github.com/langflow-ai/langflow/blob/main/LICENSE) and self-hostable. +- **LangGraph:** The LangGraph library is [MIT-licensed](https://github.com/langchain-ai/langgraph/blob/main/LICENSE), while optional hosted services have separate terms. +- **Google Gemini Enterprise Agent Platform:** Its [model documentation](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models) describes access within Google Cloud rather than a self-hosted product. +- **Microsoft Copilot Studio:** Microsoft's [model-selection documentation](https://learn.microsoft.com/en-us/microsoft-copilot-studio/authoring-select-agent-model) describes a managed service with usage-based billing. + +These descriptions were checked against the linked first-party documentation as of November 2026. Teams comparing licensing models can also read the [open-source AI agents and platforms guide](https://www.sim.ai/library/open-source-ai-agent-platforms). diff --git a/apps/sim/content/library/ai-agents-in-procurement/index.mdx b/apps/sim/content/library/ai-agents-in-procurement/index.mdx index 91eb36ba015..77a59da2d50 100644 --- a/apps/sim/content/library/ai-agents-in-procurement/index.mdx +++ b/apps/sim/content/library/ai-agents-in-procurement/index.mdx @@ -3,27 +3,27 @@ slug: ai-agents-in-procurement title: 'AI Agents in Procurement: A Comprehensive Guide' description: 'AI agents in procurement automate intake, sourcing, contracts, and supplier risk. Learn what they do, where they add value, and how to build your own.' date: 2026-07-21 -updated: 2026-11-02 +updated: 2026-11-05 authors: - andrew -readingTime: 15 +readingTime: 17 tags: [AI Agents, Procurement, Automation, Sim] ogImage: /library/ai-agents-in-procurement/cover.jpg ogAlt: AI agents in procurement automating intake, sourcing, contracts, and supplier risk. draft: false faq: - q: "What are AI agents in procurement?" - a: "AI agents in procurement are software programs that use an LLM to interpret a goal, plan steps, and act across your systems with limited supervision. They handle tasks like intake and routing, sourcing research, contract renewals, PO creation, and supplier risk monitoring, escalating key decisions to a human. AI agents in procurement are software systems that interpret purchasing information, use approved tools and data, and complete multi-step tasks such as intake triage, vendor review, record matching, and contract analysis." + a: "AI agents in procurement are software systems that interpret purchasing information, use approved tools and data, and complete multi-step tasks such as intake triage, vendor review, record matching, and contract analysis. They escalate key decisions to a human." - q: "How are AI agents different from RPA or traditional procurement software?" - a: "Traditional software and RPA bots follow fixed, predefined rules and break when inputs change. AI agents reason over context, interpret messy or unstructured data, and adapt across multiple steps. Rules-based tools suit stable, high-volume work, while agents handle judgment-heavy tasks." + a: "Traditional software and RPA bots follow fixed, predefined rules, while AI agents interpret messy or unstructured data and adapt across multiple steps. Rules-based tools suit stable, high-volume work, while agents handle judgment-heavy tasks." - q: "What procurement tasks can AI agents automate first?" - a: "AI agents can start with narrow, low-risk procurement tasks. Good starting points include intake and orchestration, sourcing research, contract renewals, purchase order creation, and supplier risk monitoring. Start narrow with one low-risk, repeatable task, assess the value added by the agent, then expand to adjacent workflows." + a: "Good starting points include intake and orchestration, sourcing research, contract renewals, purchase order creation, and supplier risk monitoring. Start with one low-risk, repeatable task, assess the value added by the agent, then expand to adjacent workflows." - q: "Will AI agents replace procurement jobs?" - a: "Procurement AI agents augment rather than replace accountable procurement teams. Agents clear repetitive, transactional work rather than replacing the function wholesale. Procurement professionals shift toward orchestration, oversight, supplier relationships, and category strategy. They take on more high-level, strategic work as more routine tasks are automated." + a: "Procurement AI agents augment rather than replace accountable procurement teams by clearing repetitive, transactional work. Procurement professionals remain responsible for orchestration, oversight, supplier relationships, category strategy, and binding decisions." - q: "Do I need to code to build a procurement agent?" - a: "Sim does not require coding to build a procurement agent. No. In an AI workspace like Sim, you can build agents visually with drag-and-drop blocks or conversationally by describing what you want. Coding is optional for teams that want deeper customization." + a: "Sim does not require coding to build a procurement agent because teams can build agents visually or conversationally. Coding is optional for teams that want deeper customization." - q: "How do I keep procurement agents secure and compliant?" - a: "Procurement teams keep agents secure and compliant through layered technical and organizational controls. Set clear guardrails and thresholds, and require human approval on decisions that touch spend. Use role-based access control, audit trails, and self-hosting or bring-your-own-keys for data control. Choose a platform with SOC2 compliance, and self-hosting for data-residency needs, to meet enterprise standards. Procurement teams should verify current plan availability for these controls; the platform details in this guide are current as of October 2026." + a: "Procurement teams keep agents secure and compliant through scoped access, deterministic thresholds, human approval, audit trails, and appropriate deployment controls. Teams should verify current plan availability for these controls; the platform details in this guide are current as of November 2026." - q: "How are AI agents used in procurement?" a: "AI agents in procurement collect missing request details, assemble vendor evidence, compare procurement records, inspect contract clauses, and route exceptions to authorized reviewers." - q: "What procurement tasks can AI agents automate?" @@ -57,19 +57,39 @@ faq: - q: "Is Sim open source?" a: "Sim's core is Apache 2.0 open source, while code in apps/sim/ee is governed by the separate Sim Enterprise License; production use of those enterprise features requires an active Sim Enterprise subscription." - q: "Can Sim be self-hosted for procurement workflows?" - a: "Sim, as of October 2026, can be self-hosted for procurement workflows, including deployments that use Ollama, vLLM, LM Studio, or LiteLLM for local models." + a: "Sim, as of November 2026, can be self-hosted for procurement workflows, including deployments that use Ollama, vLLM, LM Studio, or LiteLLM for local models." - q: "Does Sim support bring-your-own-key models?" - a: "Sim, as of October 2026, supports workspace BYOK keys on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise." + a: "Sim, as of November 2026, supports workspace BYOK keys on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise." - q: "Is n8n open source?" - a: "n8n, as of October 2026, is source-available under the Sustainable Use License rather than OSI-approved open source." + a: "n8n, as of November 2026, is source-available under the Sustainable Use License rather than OSI-approved open source." - q: "Is Sim or n8n better for procurement agents?" - a: "Sim is the stronger fit for teams prioritizing a dedicated open-source AI workspace and model-driven agent workflows, while n8n is a strong fit for teams prioritizing integration-led automation under its source-available license." + a: "Sim fits teams prioritizing a dedicated open-source AI workspace and model-driven agent workflows, while n8n fits teams prioritizing integration-led automation under its source-available license." - q: "What should a procurement AI agent never do autonomously?" a: "A procurement AI agent should never bypass policy, invent evidence, expand its own permissions, release payment, accept contractual obligations, or approve a material exception without the controls and accountable authorization required by the organization." + - q: "How do AI agents compare vendor quotes?" + a: "AI procurement agents compare vendor quotes by extracting common fields, normalizing units and currencies, applying predetermined rules, and presenting source evidence for human review. Models are useful for reading inconsistent documents, but deterministic logic should calculate totals and enforce thresholds." + - q: "How can procurement teams stop an AI agent from inventing missing quote data?" + a: "Procurement teams can stop an AI agent from inventing missing data by requiring structured outputs, marking absent fields as missing, retaining source references, and routing incomplete quotes to a person. The workflow should never convert an unknown delivery date, fee, warranty, or payment term into an assumed value." + - q: "How do AI procurement agents maintain audit trails?" + a: "AI procurement agents maintain audit trails by recording source quotes, extracted values, policy checks, approval responses, timestamps, user identities, and downstream actions in a durable system of record. The record should make it possible to reconstruct why a vendor was recommended and who authorized the purchase." + - q: "What data should a vendor quote comparison table include?" + a: "A vendor quote comparison table should include supplier identity, item, quantity, currency, unit price, fees, total evaluated cost, delivery date, payment terms, warranty, expiration date, missing fields, and source references. The table should preserve original values alongside any normalized values or currency conversions." + - q: "Should an AI agent choose the winning supplier?" + a: "An AI procurement agent should rank suppliers against an approved policy, but the procurement team should make the final selection when the decision involves spending authority, strategic judgment, or regulatory obligations. The agent should explain every score and show the underlying evidence." + - q: "How can AI agents track purchase orders and approvals?" + a: "AI procurement agents can track purchase orders and approvals by reading status events from the purchasing system, matching them to the correct order, recording each decision, and notifying the responsible person when action is required. The purchasing or enterprise resource planning system should remain the authoritative source for order status." + - q: "Can Sim create a purchase order automatically?" + a: "Sim can create a draft purchase order or call a connected purchasing API after the workflow confirms that the required approval was granted. The approved branch should be separate from rejected and revision branches, and credentials should have only the permissions needed for the action." + - q: "Does Sim support human approval workflows?" + a: "Sim supports human approval workflows through Human in the Loop, which pauses a run and resumes it with submitted form fields. A downstream Condition must inspect the approval field and route approved, rejected, and revision outcomes." + - q: "Does Sim provide audit logs for procurement workflows?" + a: "Sim Enterprise includes audit logs in apps/sim/ee under the Sim Enterprise License, while any Sim workflow can be designed to write procurement decision records to an external system of record. Production use of Sim Enterprise features requires an active Sim Enterprise subscription." --- AI agents in procurement automate information-heavy work such as intake, vendor review, purchase-order matching, and contract checks while routing consequential decisions to authorized people. Unlike fixed automation, a procurement agent can interpret unstructured requests, gather context from approved systems, apply policies, and recommend a next action. The safest design combines agentic reasoning with deterministic rules, scoped system access, audit logs, and human approval before financial or contractual commitments. +For recurring vendor quote comparison and purchase order approvals, Sim fits procurement teams that want structured model outputs, deterministic policy checks, and explicit human review in one visual workflow. n8n fits technical teams that want to assemble similar controls in an integration-led, source-available automation system. + Procurement leaders are being asked to move faster and spend less while keeping a close watch on supplier risk, usually with the same headcount and a queue full of manual intake, purchase orders, and email threads. AI agents in procurement offer a practical way out: software that reads a request, plans the steps, and acts across your systems with light supervision. This guide covers what these agents are, where they add the most value, and how to get one running. Two decisions are particularly important, so we'll focus there: which procurement tasks to automate first, and whether to buy a pre-built agent or build your own. @@ -77,7 +97,7 @@ This guide covers what these agents are, where they add the most value, and how ## Key Takeaways - **AI agents are autonomous coworkers:** AI agents use an LLM to interpret a goal, plan steps, and act across your procurement systems with limited human oversight. -- **Adoption is accelerating:** 90 percent of procurement leaders have considered or are already using AI agents to optimize operations, as of October 2026, per an [Icertis and ProcureCon survey](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/). +- **Adoption is accelerating:** 90 percent of procurement leaders have considered or are already using AI agents to optimize operations, as of November 2026, per an [Icertis and ProcureCon survey](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/). - **Best first tasks include** intake and orchestration, sourcing research, contract renewals, PO creation, and supplier risk monitoring. - **Buy vs build:** Buy for a narrow, standardized need; build when workflows are unique, systems are many, and data control matters. - **Start narrow:** Implement one low-risk agent with clear guardrails and well-defined human approvals, then monitor and expand. @@ -124,7 +144,7 @@ AI agents deliver the most procurement value on repeatable knowledge work with a The strongest procurement use cases require information from multiple systems or documents, involve inputs that are unstructured or inconsistent, and have a policy owner who can approve exceptions or binding actions. Agents should handle collection, classification, comparison, and recommendation, while deterministic software enforces calculations, thresholds, permissions, and system-of-record updates. -**Intake and orchestration.** Agents translate a business request into structured intake, check policy and spend thresholds, then route the buyer to the right channel or an existing contract. This matches what practitioners already prioritize: a recent [Ironclad survey], reviewed as of October 2026,(https://ironcladapp.com/resources/webinars/virtual-panel-state-of-ai-procurement) found the top AI use cases were tracking supplier contractual commitments (77%) and workflow automation and procurement orchestration (67%). +**Intake and orchestration.** Agents translate a business request into structured intake, check policy and spend thresholds, then route the buyer to the right channel or an existing contract. This matches what practitioners already prioritize: a recent [Ironclad survey](https://ironcladapp.com/resources/webinars/virtual-panel-state-of-ai-procurement), reviewed as of November 2026, found the top AI use cases were tracking supplier contractual commitments (77%) and workflow automation and procurement orchestration (67%). **Strategic sourcing.** Agents run always-on market research, shortlist suppliers, analyze bids, and prepare recommendations. Humans use these resources to decide who to award a contract to. @@ -146,6 +166,23 @@ AI agents can automate procurement intake, vendor review, purchase-order matchin These workflow patterns do not represent fully autonomous purchasing. Access should follow least-privilege principles, and every write action should be limited to the fields and systems the workflow actually needs. +### our procurement team manually compares vendor quotes every week, what ai agent tools can automate that research and comparison + +Sim can automate recurring vendor quote research and comparison by combining quote collection, structured model output, deterministic calculations, and human approval in one workflow. + +A worked flow can: + +1. Collect quotes from an approved inbox, form, shared folder, webhook, or vendor API while retaining a reference to each source. +2. Extract vendor name, quote identifier, item, quantity, unit price, currency, taxes, fees, shipping, delivery date, payment terms, warranty, and expiration date. +3. Normalize every quote into one schema and mark missing or ambiguous fields instead of inventing values. +4. Use deterministic logic for currency conversion, totals, thresholds, disqualifying conditions, and the procurement team's documented comparison policy. +5. Present the original evidence, normalized values, policy results, and recommendation in a [Human in the Loop block](https://docs.sim.ai/workflows/blocks/human-in-the-loop), which pauses the run and returns the approver's submitted form fields. +6. Use a downstream [Condition](https://docs.sim.ai/workflows/blocks/condition) to route approve, reject, and revision outcomes separately. The Human in the Loop block collects the decision but does not enforce it by itself. +7. Create a draft purchase order or call the purchasing system only on the approved branch, while rejected and revision branches record the reason and notify the responsible person. +8. Write source references, normalized quote rows, policy calculations, approval fields, identity, timestamps, and final actions to the procurement system of record. + +This design reduces repetitive comparison work while preserving purchasing authority and a reproducible decision record. + ## How does an AI procurement intake agent work? A procurement intake agent turns an incomplete employee request into a structured, reviewable purchasing packet without giving the agent final spending authority. @@ -159,7 +196,7 @@ A procurement intake agent turns an incomplete employee request into a structure 7. A budget owner or procurement reviewer approves, rejects, or requests changes. 8. Only after approval does the workflow create or update the record in the procurement system. -This design reduces back-and-forth without allowing model output to replace purchasing policy. [Human-in-the-loop workflows](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) provide an explicit pause for a reviewer response. +This design reduces back-and-forth without allowing model output to replace purchasing policy. [Human-in-the-loop workflows](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) explain the review pattern, while [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows) compares approval capabilities across builders. ## How can AI agents help with vendor review? @@ -207,23 +244,31 @@ Procurement teams should buy a pre-built agent for a narrow, standardized need a | Vendor lock-in | High | Low, open options | | Data control and governance | Vendor-defined | You define it | -Sim is the open-source AI workspace where procurement and IT teams build agents visually, conversationally, or with code. As of October 2026, it connects [270+ integrations](https://www.sim.ai/pricing) including Salesforce, Slack, Gmail, databases, and ERP systems, without adopting a rigid suite. +Sim is the open-source AI workspace where procurement and IT teams build agents visually, conversationally, or with code. As of November 2026, it connects [270+ integrations](https://www.sim.ai/pricing) including Salesforce, Slack, Gmail, databases, and ERP systems, without adopting a rigid suite. -As of October 2026, its governance options include real-time collaboration, role-based access control, self-hosting for data residency, bring-your-own-keys, and SOC2 compliance. If you're weighing platforms more broadly, [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) compares the field. +As of November 2026, its governance options include real-time collaboration, role-based access control, self-hosting for data residency, bring-your-own-keys, and SOC2 compliance. If you're weighing platforms more broadly, [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) compares the field. ## What are the key differences between Sim and n8n for procurement agents? -Sim is designed as the open-source AI workspace for building and managing agents, while n8n is a source-available workflow automation product with broad integration-oriented automation capabilities. +### which ai agent platform is best for procurement teams automating vendor comparisons + +Sim fits procurement teams that need flexible quote normalization, explicit approval gates, and deployment control in the same visual workflow. n8n fits technical teams that prefer to construct quote comparison and approval processes from workflow nodes and connected systems. -As of October 2026, the key facts are: +As of November 2026, the procurement comparison is: -| Platform | License and self-hosting | Billing basis | Procurement fit | +| Platform | Vendor quote comparison | Approval approach | Deployment, license, and billing | | --- | --- | --- | --- | -| Sim | [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in apps/sim/ee is covered by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); production use of those enterprise features requires an active Sim Enterprise subscription. Sim supports self-hosting. | [Sim Cloud supports workspace BYOK on any plan, and hosted model usage is recorded at the applicable Sim rate](https://docs.sim.ai/platform/costs#bring-your-own-key-byok). | Strong fit for model-centric workflows that combine tools, conditions, human review, and agent management. | -| n8n | n8n uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is source-available rather than OSI-approved open source, and n8n supports self-hosting subject to that license. | [n8n Cloud pricing](https://n8n.io/pricing/) is organized around workflow executions. | Strong fit for integration-heavy automation, including workflows that add AI steps to an established automation pattern. | +| Sim | Model output schemas and workflow logic can turn quote data into comparable records, while deterministic steps handle calculations and policy checks. | [Human in the Loop](https://docs.sim.ai/workflows/blocks/human-in-the-loop) pauses the run and returns form fields, then a downstream [Condition](https://docs.sim.ai/workflows/blocks/condition) routes approve, reject, and revision outcomes. | [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and supports self-hosting, while code in apps/sim/ee is covered by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); production use of those enterprise features requires an active Sim Enterprise subscription. [Sim Cloud supports workspace BYOK on any plan](https://docs.sim.ai/platform/costs#bring-your-own-key-byok). | +| n8n | Custom workflow logic can normalize quote data and connect external services. | n8n documents [human review for AI tool calls](https://docs.n8n.io/build/integrate-ai/ai-examples/human-in-the-loop-for-tools), which pauses a workflow before a reviewed tool executes. | n8n uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is source available rather than OSI-approved open source, and supports self-hosting subject to that license. [n8n Cloud pricing](https://n8n.io/pricing/) is based on monthly workflow executions. | + +Neither platform removes the need for procurement policy, access controls, deterministic validation, and accountable approval. The fit depends on whether the team is primarily building and governing model-driven agents or extending integration-led automation. + +### top ai agent tools for procurement teams tracking purchase orders and approvals + +Sim and n8n both fit configurable purchase order tracking and approval workflows. Sim provides a native pause for structured human input followed by explicit Condition routing, while n8n lets technical teams assemble approval processes from workflow nodes and connected systems. -Neither platform removes the need for procurement policy, access controls, deterministic validation, and accountable approval. The better fit depends on whether the team is primarily building and governing model-driven agents or extending integration-led automation. +The approved amount, supplier, purchase order identifier, approver, decision, timestamp, and source quote references should be written to the procurement or enterprise resource planning system. That system should remain the authoritative source for order status. ## How to Get Started With Procurement Agents @@ -283,7 +328,7 @@ A strong first pilot has frequent cases, accessible source data, an existing pol ## Challenges and Best Practices -Procurement AI agent adoption succeeds when teams address data, integration, change-management, and trust constraints before scaling. Adoption is rarely painless. The most significant hurdles are messy or siloed data, integration complexity across ERP and spend tools, change management, and trust in autonomous decisions. Data is often the biggest blocker: [GEP-supported research], reviewed as of October 2026,(https://www.gep.com/blogs/strategy/clean-data-agentic-ai-orchestration-key-to-procurement-transformation) found that more than half of organizations (53%) do not have their key procurement data integrated into a single system or architecture. As of October 2026, Icertis [reported similar friction](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/), with integration issues (88%) and data quality issues (75%) detracting from procurement confidence in AI. +Procurement AI agent adoption succeeds when teams address data, integration, change-management, and trust constraints before scaling. Adoption is rarely painless. The most significant hurdles are messy or siloed data, integration complexity across ERP and spend tools, change management, and trust in autonomous decisions. Data is often the biggest blocker: [GEP-supported research](https://www.gep.com/blogs/strategy/clean-data-agentic-ai-orchestration-key-to-procurement-transformation), reviewed as of November 2026, found that more than half of organizations (53%) do not have their key procurement data integrated into a single system or architecture. As of November 2026, Icertis [reported similar friction](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/), with integration issues (88%) and data quality issues (75%) detracting from procurement confidence in AI. A few best practices keep programs on track. Clean and consolidate your data first, set clear standards and guardrails, keep humans in the loop on strategic decisions, and introduce agents gradually. This incremental path is the norm, since a lot of companies are already using agentic AI in some cross-functional capacity, most of them starting small. @@ -309,6 +354,6 @@ These controls preserve accountability while agents handle collection, classific ## The Bottom Line -Procurement teams should begin with a narrow, measurable agent that preserves human authority over spending, risk, and contractual commitments. Start gradually and ship one narrow agent this quarter – the teams pulling ahead are the ones learning from a live use case rather than taking an over-theoretical approach. Pick a repeatable task like supplier email triage, wire in your real systems and approvals, and measure the time it saves. +Procurement teams should begin with a narrow, measurable agent that preserves human authority over spending, risk, and contractual commitments. Start gradually and ship one narrow agent this quarter. Teams learn more from a live use case than from an over-theoretical approach. Pick a repeatable task like supplier email triage, wire in your real systems and approvals, and measure the time it saves. You can [build that first agent in Sim](https://www.sim.ai) from a template today, then expand once the results are on the table. diff --git a/apps/sim/content/library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/index.mdx b/apps/sim/content/library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/index.mdx new file mode 100644 index 00000000000..ceb9d11b3eb --- /dev/null +++ b/apps/sim/content/library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/index.mdx @@ -0,0 +1,253 @@ +--- +slug: best-ai-agents-content-repurposing-fact-checking-news-monitoring +title: 'Best AI Agents for Content Operations: Repurposing, Fact Checking, and News Monitoring' +seoTitle: 'AI Agents for Content Operations' +description: 'Compare AI agents for content repurposing, fact checking, and news monitoring, including approval, research, scheduling, and delivery workflows.' +date: 2026-11-06 +updated: 2026-11-06 +authors: + - andrew +readingTime: 13 +tags: [AI Agents, Content Operations, Marketing Automation, Sim] +ogImage: /library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/cover.jpg +draft: false +faq: + - q: "Which AI agent tools help marketing teams repurpose content?" + a: "Sim fits marketing teams that need repurposing, source checking, human approval, and delivery in one reusable workflow. Tugan is a focused repurposing tool, while Zapier, Make, and n8n can connect generation steps to a broader marketing stack." + - q: "What is the best AI agent for fact checking content before publishing?" + a: "Sim fits fact-checking workflows that need live retrieval, structured evidence, source links, and mandatory human review. Webcite is a focused alternative for source-backed checking, and Manus is useful for interactive web research." + - q: "What is the best AI agent for monitoring and summarizing industry news?" + a: "Sim fits news-monitoring workflows that must run on a schedule, remove duplicate stories, retain original sources, and deliver a digest to Slack or email. A dedicated monitoring service can suit teams that prefer a narrower product." + - q: "Can AI agents repurpose one article for multiple marketing channels?" + a: "Sim can repurpose one article into separate drafts for social media, newsletters, sales enablement, and other channels while preserving the approved source material. A human approval step should review each channel draft before distribution." + - q: "Can an AI agent guarantee that content is factually correct?" + a: "No AI agent, including Sim, can guarantee that content is factually correct. Sim can make checking more defensible by exposing claims, current sources, contradictory evidence, uncertainty, and an editorial approval decision." + - q: "Can AI agents cite their sources?" + a: "Sim can retain source URLs, publishers, dates, and evidence excerpts as structured workflow data and include them in the final output. Teams should require the agent to retrieve and preserve sources rather than asking a model to recreate citations from memory." + - q: "Should AI-generated marketing content require human approval?" + a: "Teams should use human approval before externally distributing factual, regulated, brand-sensitive, or high-impact marketing content. Sim’s Human in the Loop block pauses the run, and a downstream Condition must evaluate the reviewer’s approve-or-reject response." + - q: "How can an AI agent monitor industry news without repeating the same story?" + a: "Sim can normalize URLs and titles, cluster articles about the same event, select a primary source, and summarize the incremental information from secondary coverage. The digest should retain source links so a reader can inspect the reporting directly." + - q: "Is Sim better than n8n for content operations?" + a: "Sim fits content teams that prioritize a visual builder, model choice, reusable approval steps, and an accessible workspace for technical and non-technical collaborators. n8n fits technical teams that prioritize granular automation control and its self-hosting ecosystem." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, not open source under an OSI-approved license. The license permits many internal uses but includes restrictions that buyers should review directly." + - q: "Is Sim open source?" + a: "Sim has separate core and enterprise licenses. Buyers should review the Apache 2.0 root license and the Sim Enterprise License before choosing a deployment." + - q: "Is Sim free?" + a: "Self-hosted operators still pay for infrastructure and model usage, and Sim Cloud plans have their own current pricing. Production use of code in apps/sim/ee requires an active Sim Enterprise subscription." + - q: "What is the best open-source Zapier alternative for AI content workflows?" + a: "Sim fits teams seeking an open-source Zapier alternative for AI content workflows when model choice, multi-step agent logic, live research, and human approval matter more than maintaining a conventional Zap-style automation system. Buyers should review its separate core and enterprise licenses for their intended deployment." + - q: "How does Sim compare with Gumloop for content workflows?" + a: "Sim fits teams that prioritize an open-source core, self-hosting, local-model support on self-hosted deployments, and explicit control over multi-step agent logic. Gumloop may suit cloud-first teams that prefer its managed automation experience." + - q: "What is the best AI agent builder?" + a: "The right AI agent builder depends on deployment, model, governance, integration, and collaboration requirements. Sim fits teams that want the open-source AI workspace with a visual builder, model choice, deployment options, and reusable workflows." + - q: "Do content teams need a dedicated point tool or an AI agent platform?" + a: "Sim fits teams when content repurposing, fact checking, monitoring, approval, and delivery must work together across existing systems. A dedicated tool such as Tugan or Webcite fits a narrower job when its built-in process already matches the requirement." + - q: "Can Sim post a scheduled news digest to Slack?" + a: "Sim can run a scheduled monitoring workflow and send a source-linked digest to Slack. The workflow can collect stories, remove duplicates, evaluate relevance, summarize the selected items, and require approval before delivery." + - q: "Can Sim use local models for content operations?" + a: "Sim can use Ollama, vLLM, LM Studio, or LiteLLM on any self-hosted Sim deployment without requiring Enterprise. Local models are a self-hosting capability and are not presented as a Sim Cloud connection option." + - q: "Does Sim’s Guardrails block automatically stop unsupported content?" + a: "Sim’s Guardrails block reports whether its configured checks passed or failed but does not automatically stop the workflow. A downstream Condition must route failed content away from publishing or delivery steps." +--- + +## TL;DR + +Sim fits content operations teams that need one reusable workflow to repurpose material, check claims against live sources, pause for human approval, and deliver scheduled updates. [n8n](https://docs.n8n.io/deploy/host-n8n/) fits technical self-hosters, while [Zapier](https://zapier.com/pricing) and [Make](https://www.make.com/en/pricing) fit teams that prioritize cloud application automation. [Tugan](https://tugan.ai/use-cases/creators) and [Webcite](https://webcite.co/api-docs/verify) address narrower repurposing and verification tasks. + +| Platform | Best fit | Content repurposing | Fact checking | News monitoring | Approval and delivery | +|---|---|---|---|---|---| +| Sim | One customizable content operations system | Multi-channel generation with model choice | Live search, structured evidence, and cited output | Scheduled collection, filtering, and summarization | Human in the Loop, Conditions, Slack, email, and application integrations | +| [n8n](https://docs.n8n.io/deploy/host-n8n/) | Technical teams that want self-hosted automation | Custom multi-step workflows | Search and API steps can support evidence gathering | Schedule-based monitoring workflows | Configurable review patterns and integrations | +| [Zapier](https://zapier.com/pricing) | Cloud teams connecting SaaS applications | Application-to-application content routing | AI and search actions can be assembled into checks | Recurring workflows and application triggers | Review and delivery through connected applications | +| [Make](https://www.make.com/en/pricing) | Visual automation with detailed data mapping | Flexible transformation scenarios | Search and AI modules can be combined | Scheduled scenarios for collection and summaries | Visual routing across content and collaboration tools | +| [Manus](https://manus.im/docs/introduction/plans) | Ad hoc research and content tasks | Research-led transformation | Interactive web research | Requested research briefs | More suited to requested tasks than a shared content pipeline | +| [Tugan](https://tugan.ai/use-cases/creators) | Dedicated content repurposing | Focused repurposing experience | Not its primary role | Not its primary role | Can be paired with a separate review and delivery workflow | +| [Webcite](https://webcite.co/api-docs/verify) | Dedicated source-backed fact checking | Not its primary role | Focused evidence and citation workflow | Not its primary role | Can be used as a checking step before editorial approval | +| [ToolHalla](https://toolhalla.ai/huginn) | Source-linked AI tool signals | Not its primary role | Source review remains necessary | A curated feed rather than a general monitoring product | Can inform a separate editorial workflow | + +This comparison reflects vendor information available as of November 2026. It evaluates fit for three content operations jobs rather than treating every product as a general-purpose AI agent platform. + +## Which AI agent tools help marketing teams repurpose content? + +Sim fits marketing teams that want repurposing to run as a repeatable, governed workflow rather than a one-off generation task. A Sim workflow can accept a published article, transcript, webinar summary, or campaign brief, extract reusable ideas, generate channel-specific drafts, pause for editorial input, and route approved versions to Slack, email, a CMS, or another connected application. + +The right choice depends on what the team means by “repurpose”: + +- **Sim** fits work that combines source retrieval, model choice, reusable workflow logic, approval, and downstream delivery. +- **Tugan** provides [tools for turning articles and long-form material into channel-specific marketing content](https://tugan.ai/tools/blog-post-to-social-posts). +- **Zapier** fits teams whose central requirement is moving source material and generated drafts among cloud applications under its [task-based automation model](https://help.zapier.com/hc/en-us/articles/16051471305357-How-to-select-your-Zapier-plan). +- **Make** fits operators who want the [visual workflow builder, routers, filters, and application connections](https://www.make.com/en/pricing) in its scenarios. +- **n8n** fits a technical team that wants to assemble and [self-host a customized pipeline](https://docs.n8n.io/deploy/host-n8n/). +- **Manus** fits interactive research and transformation tasks, with [scheduled automations and research capabilities varying by plan](https://manus.im/docs/introduction/plans). + +Teams focused on producing net-new social copy can also read [Best AI Agents for Drafting Social Media Posts: 4 Platforms Compared](https://www.sim.ai/library/best-ai-agents-for-drafting-social-media-posts). + +## What should a fact-checking agent do before publication? + +Sim fits fact-checking work when the workflow must retrieve live sources, return structured findings, and require a person to approve the draft. Sim can separate claim extraction, web research, source comparison, citation formatting, risk classification, and editorial review into visible workflow steps. + +No AI agent can guarantee that a draft is true. A defensible fact-checking workflow should expose evidence and uncertainty so an editor can decide whether each claim is supported. + +A useful workflow should: + +1. Extract checkable factual claims from the draft. +2. Distinguish factual claims from opinions, predictions, and marketing language. +3. Search current primary or authoritative sources. +4. Record the source URL, publisher, publication date, and relevant evidence for each claim. +5. Mark claims as supported, contradicted, inconclusive, or time-sensitive. +6. Flag sources that merely repeat the same unsupported statement. +7. Return corrected wording without silently changing the original draft. +8. Pause for a human decision before the content moves to publishing systems. + +[Webcite verifies a claim against sources and returns citations, source stances, and a verdict](https://webcite.co/api-docs/verify), which makes it the most focused checking service in this comparison. [Manus documents parallel research capabilities](https://www.manus.im/docs/features/wide-research), while n8n, Zapier, and Make can orchestrate checking steps when teams are prepared to design the retrieval, evidence, and review logic themselves. + +## What should an industry news-monitoring agent do? + +Sim fits industry news monitoring when a team needs scheduled collection, relevance filtering, deduplication, source links, model choice, and Slack delivery in one reusable workflow. ToolHalla is not a general monitoring platform, but its [Huginn feed groups source-linked AI tool signals](https://toolhalla.ai/huginn) for readers following that specific subject. + +A credible news-monitoring agent should do more than summarize a search-results page. It should retain links to original reporting, distinguish publication dates from event dates, group duplicate coverage, identify which claims come from primary sources, and explain why each item matters to the team. + +For monitoring competitor website edits rather than broader industry reporting, read [What Are the Best AI Agents for Competitor Website Change Monitoring in 2026?](https://www.sim.ai/library/best-ai-agents-competitor-website-change-monitoring-2026). For scheduled delivery patterns, read [Best AI Agent Builders for Automated Daily Email Digests](https://www.sim.ai/library/best-ai-agent-builders-automated-daily-email-digests). + +## How do Sim, n8n, Zapier, Make, and Manus compare for content operations? + +The platforms differ in how much of the research, editorial policy, deployment, and delivery process a team wants to design itself. + +### Is Sim good for content repurposing, fact checking, and news monitoring? + +Sim fits teams that want all three jobs to share reusable workflow components, credentials, model choices, and approval rules. Sim is the open-source AI workspace where teams build, deploy, and manage AI agents, and its workflow builder supports multi-step logic instead of limiting the team to a fixed prompt interface. + +Sim workflows can use search APIs for current information, including the approach described in [How to Give AI Agents Live Web Search with the You.com API in Sim](https://www.sim.ai/library/how-to-give-ai-agents-live-web-search-with-the-you-com-api-in-sim). Workspace bring-your-own-key credentials work on any Sim Cloud plan, while organization-level model keys require Pro for Teams, Max for Teams, or Enterprise according to the [Sim cost documentation](https://docs.sim.ai/platform/costs#bring-your-own-key-byok). + +Sim’s core is licensed under Apache 2.0, while code in `apps/sim/ee`, including enterprise capabilities such as SSO, SCIM, audit logs, access control, and data retention, is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Sim Enterprise subscription for production use. + +### Is n8n good for content repurposing, fact checking, and news monitoring? + +n8n fits technical teams that want deep workflow customization and a self-hosting path. Its [self-hosting documentation covers deployment on private infrastructure](https://docs.n8n.io/deploy/host-n8n/), while its workflows can connect the APIs and services needed for content operations. + +n8n requires implementation ownership because the team must define how claims are extracted, which sources are acceptable, how citations are retained, and where human review occurs. n8n uses the [Sustainable Use License and separate enterprise licensing](https://docs.n8n.io/n8n-community-license), a fair-code model rather than an OSI-approved open-source license. + +### Is Zapier good for content repurposing, fact checking, and news monitoring? + +Zapier fits a content team whose primary requirement is connecting cloud applications with minimal infrastructure ownership. Its [plans include Zaps, Tables, Forms, and an AI action layer under task-based usage](https://zapier.com/pricing), which teams can use to route source material and recurring outputs. + +Zapier is less focused on defining a fact-checking methodology. Its fit comes from cloud application connectivity and a managed automation experience, while the team still owns the evidence policy and editorial review design. + +### Is Make good for content repurposing, fact checking, and news monitoring? + +Make fits operators who want a visual representation of multi-application scenarios and detailed control over data mapping. Its [pricing page documents a visual workflow builder, routers, filters, application connections, and credit-based module actions](https://www.make.com/en/pricing). + +Make still requires the team to design the editorial policy. Visual routing alone does not determine which sources are authoritative or when a claim is safe to distribute. + +### Is Manus good for content repurposing, fact checking, and news monitoring? + +Manus fits interactive, research-heavy tasks in which an agent gathers information and produces a requested deliverable. Its [Wide Research documentation describes parallel processing for research and content tasks](https://www.manus.im/docs/features/wide-research). + +Manus can support scheduled work according to its [plan documentation](https://manus.im/docs/introduction/plans), but teams should assess whether its task model matches a shared pipeline with a fixed review gate and established content destinations. + +## How do dedicated content AI tools compare with workflow platforms? + +Tugan and Webcite reduce setup for one content job, while Sim, n8n, Zapier, and Make provide more control over the full process before and after that job. The trade-off is focus versus orchestration. + +Dedicated tools are attractive when their built-in process already matches the team’s requirements: + +- [Tugan](https://tugan.ai/use-cases/creators) transforms videos and long-form posts into formats such as social posts and newsletters. +- [Webcite](https://webcite.co/api-docs) provides claim verification, source inspection, and evidence extraction APIs. +- [ToolHalla](https://toolhalla.ai/huginn) publishes a curated, source-linked feed of AI tool signals rather than a configurable general-purpose monitoring service. + +Workflow platforms fit when the result must pass through custom business rules, several data sources, a human review step, and multiple destinations. A content team can also combine both categories by calling a specialized service from a broader orchestration workflow. + +## How can Sim repurpose a blog post into channel drafts with approval? + +Sim can turn one approved article into channel-specific drafts and pause the run before any draft reaches a distribution system. The workflow keeps source extraction, generation, review, and delivery as separate steps so an editor can see what happened. + +A practical workflow is: + +1. Trigger Sim from a CMS event, webhook, form, or manual run. +2. Retrieve the article body, title, audience, campaign goal, and approved claims. +3. Extract the central argument, supporting evidence, examples, and reusable quotations. +4. Generate separate drafts for LinkedIn, X, an email newsletter, and a sales-enablement summary. +5. Apply channel-specific length, tone, and formatting rules. +6. Run a second model step that compares every draft with the source and flags unsupported additions. +7. Use Sim’s Human in the Loop block to pause the run and collect an approve-or-reject field plus editorial notes. +8. Use a downstream Condition to route approved drafts to their destinations and rejected drafts back for revision. + +Sim’s Human in the Loop block resumes the run with submitted form fields. Approval is not automatic, so a downstream Condition must evaluate the editor’s response. Teams comparing this pattern can read [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows). + +## How can Sim fact check a draft against live web search with cited sources? + +Sim can fact check a draft by extracting atomic claims, searching current sources, and returning a structured evidence record for editorial review. The agent should never replace the draft silently or treat search-result snippets as sufficient proof. + +A practical workflow is: + +1. Accept the draft from a CMS, document store, form, or webhook. +2. Ask a model to return each factual claim as a structured item with its exact location in the draft. +3. Send each claim to a live search provider. +4. Prefer original company announcements, government records, standards bodies, peer-reviewed research, and direct documentation over pages that repeat another source. +5. Capture the source title, URL, publisher, date, quoted evidence, and retrieval time. +6. Ask a separate evaluation step to classify the claim as supported, contradicted, inconclusive, or time-sensitive. +7. Produce a review table with proposed corrections and source links. +8. Pause for an editor before sending the checked draft onward. + +Sim can add a Guardrails block to report whether output passes configured checks, but Guardrails does not stop a run by itself. A downstream Condition must route failed output away from the delivery step. + +## How can Sim monitor industry news and post a scheduled digest to Slack? + +Sim can run a scheduled research workflow that collects industry news, removes duplicate coverage, summarizes material changes, and posts a source-linked digest to Slack. The workflow can retain both included and excluded items so editors can inspect why the agent made each decision. + +A practical workflow is: + +1. Start with a daily or weekly schedule trigger. +2. Search approved queries, feeds, publications, or data services. +3. Normalize titles, publishers, URLs, dates, and excerpts. +4. Cluster duplicate stories that describe the same event. +5. Evaluate items against the team’s industries, products, competitors, and editorial priorities. +6. Retrieve the underlying pages for the highest-value items. +7. Generate concise summaries that preserve source links and distinguish reporting from analysis. +8. Send the digest to Slack, email, or another destination. + +Sim’s Wait block is not a substitute for an event trigger because Wait resumes only after a configured period. Teams should use a Schedule trigger for recurring digests and an appropriate webhook or application trigger for event-driven monitoring. + +## What should content operations teams require from an AI agent platform? + +Sim should be considered when the team needs live retrieval, visible workflow logic, human approval, scheduled execution, model choice, and delivery across existing tools. The following requirements separate a production content operations system from a stand-alone text generator. + +### Can the agent retain sources and citations? + +Sim can preserve source metadata as structured workflow data instead of relying on a model to reproduce links from memory. Require every factual output to retain the source URL, publisher, date, evidence excerpt, and retrieval time. + +### Can the agent stop before content is distributed? + +Sim can pause for human input and route the run according to the reviewer’s response. Require an explicit approval field, a downstream conditional branch, and a failure path that cannot reach the distribution destination. + +### Can the agent run on a schedule and respond to events? + +Sim supports scheduled and event-driven workflow designs through the appropriate triggers. Use schedules for recurring news scans and webhooks or application events when a new draft or source item should start a run. + +### Can the team choose its AI models? + +Sim supports model choice and bring-your-own-key configurations rather than forcing every step through one model provider. On self-hosted Sim, teams can connect Ollama, vLLM, LM Studio, or LiteLLM through the configuration described in the [Sim self-hosting documentation](https://docs.sim.ai/platform/self-hosting). + +### Can the agent show why it produced an answer? + +Sim can expose intermediate research, structured claim records, model outputs, and routing decisions in a multi-step workflow. Content teams should require inspectable evidence because a polished final paragraph is not proof that the underlying claim is accurate. + +## Which platform facts should buyers know at a glance? + +Sim, n8n, Zapier, Make, and Manus differ in licensing, deployment, and billing approach as of November 2026. + +- **Sim:** Sim’s core is Apache 2.0, while enterprise code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Self-hosting is available, and cloud model usage can use Sim-hosted credentials or workspace BYOK. +- **n8n:** n8n uses its [source-available Sustainable Use License](https://docs.n8n.io/n8n-community-license), offers [self-hosting](https://docs.n8n.io/deploy/host-n8n/), and prices cloud plans by [monthly workflow executions](https://n8n.io/pricing/). +- **Zapier:** Zapier is a managed cloud service with [task-based plan allowances](https://zapier.com/pricing). +- **Make:** Make is a managed cloud service with [credit-based module actions and plan allowances](https://www.make.com/en/pricing). +- **Manus:** Manus is a vendor-managed agent service with a [credit-based plan model](https://manus.im/docs/introduction/plans). + +## Which AI agent fits a content operations team? + +Sim fits a content operations team that needs repurposing, fact checking, and news monitoring to work as one governed system. n8n fits technical teams that prioritize self-hosting and are willing to build more of the editorial control layer themselves. + +Choose [Zapier](https://zapier.com/pricing) when cloud application connectivity and operational familiarity matter more than model or deployment control. Choose [Make](https://www.make.com/en/pricing) when visual scenario design and detailed data mapping are central. Choose [Manus](https://www.manus.im/docs/features/wide-research) for interactive research assignments, [Tugan](https://tugan.ai/use-cases/creators) for focused repurposing, and [Webcite](https://webcite.co/api-docs/verify) for focused source-backed checking. + +The decision should follow the work: identify the required sources, review gates, schedule, models, deployment constraints, and destinations, then test the complete path with representative content before adopting it. diff --git a/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx b/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx index d5caca88d73..242a7608cec 100644 --- a/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx +++ b/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx @@ -1,51 +1,49 @@ --- slug: best-ai-agents-for-data-extraction-and-rag-in-2026 title: 'Best AI Agents for Data Extraction and RAG in 2026' -description: 'Compare the best AI agents for data extraction and RAG in 2026, including Sim, n8n, Unstructured, and LlamaIndex for visual, automation, document, and code-first workflows.' -seoDescription: 'Compare AI agents that pull data from PDFs, emails and websites and answer questions from your documents: Sim, n8n, Unstructured and LlamaIndex.' +description: 'Compare AI agents for data extraction and RAG in 2026, including Sim, n8n, Unstructured, LlamaIndex, Dataiku, Dify, and UiPath.' +seoDescription: 'Compare data extraction and RAG agents: Sim, n8n, Unstructured, LlamaIndex, Dataiku, Dify, and UiPath for PDFs, email, and internal documents.' date: 2026-07-01 -updated: 2026-10-14 +updated: 2026-11-05 authors: - andrew -readingTime: 19 +readingTime: 24 tags: [AI Agents, Data Extraction, RAG, Sim] ogImage: /library/best-ai-agents-for-data-extraction-and-rag-in-2026/cover.jpg draft: false faq: - q: "What is the best AI agent for data extraction and RAG?" - a: "Sim is the best overall choice for teams that want to combine web, PDF, and email ingestion with structured extraction, validation, branching, retrieval, model choice, tools, and human review in one visual workflow. The right choice still depends on document complexity, deployment requirements, existing systems, and whether the team prefers visual or code-first development." + a: "Sim fits teams that want to combine web, PDF, and email ingestion with structured extraction, validation, branching, retrieval, model choice, tools, and human review in one visual workflow. The right choice depends on document complexity, deployment requirements, existing systems, and whether the team prefers visual or code-first development." - q: "What is the difference between data extraction, retrieval, RAG, and agentic RAG?" a: "Data extraction converts source content into structured information, retrieval finds relevant information, RAG gives retrieved context to a model, and agentic RAG lets an agent choose and repeat those operations while pursuing a goal. These stages should be evaluated separately because a fluent final answer can conceal extraction or retrieval failures." - q: "How should I evaluate an AI agent for data extraction and RAG?" a: "Sim recommends evaluating extraction fidelity, retrieval quality, answer groundedness, workflow reliability, observability, deployment control, licensing, latency, and total cost on a representative test set. A platform should not be selected from a polished demonstration alone." - - q: "Which AI agent is best for extracting data from PDFs?" - a: "Sim is the best fit when PDF extraction must feed a visual agentic workflow, while Unstructured is a stronger specialist candidate when document partitioning and preprocessing are the main problem. Scanned files, tables, forms, multi-column layouts, and handwriting should be tested separately." + - q: "Which AI agent extracts data from PDFs?" + a: "Sim fits teams whose PDF extraction must feed a visual agent workflow, Unstructured fits specialist parsing and preprocessing, and UiPath fits PDF processing tied to enterprise RPA. Scanned files, tables, forms, multi-column layouts, and handwriting should be tested separately." - q: "Which AI agent is best for code-first RAG development?" - a: "LlamaIndex is the strongest choice in this comparison for developers who want code-first control over ingestion, indexing, retrieval, and response synthesis. Sim is better suited to teams that want those stages represented as a visual, inspectable workflow." + a: "LlamaIndex fits developers who want code-first control over ingestion, indexing, retrieval, and response synthesis. Sim fits teams that want those stages represented as a visual, inspectable workflow." - q: "Is n8n good for RAG workflows?" a: "n8n is a strong choice for RAG workflows that must connect to a broad business automation estate. n8n should be tested carefully when retrieval evaluation, document-specific processing, or complex agent state is central to the application." - q: "Is Sim open source?" - a: "Sim's core is open source under the OSI-approved Apache License 2.0 and supports free self-hosting. Enterprise features in apps/sim/ee use the separate Sim Enterprise License, which requires an Enterprise subscription for production use. Teams should consult Sim's official pricing page for current hosted product terms." + a: "Sim's core is open source under the OSI-approved Apache License 2.0 and can be self-hosted. Enterprise features in apps/sim/ee use the separate Sim Enterprise License and require an Enterprise subscription for production use; teams should consult Sim's official pricing page for current hosted product terms." - q: "Is n8n open source?" a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license. The license permits many internal and self-hosted uses but includes restrictions that teams should review before commercial redistribution or offering hosted n8n to third parties." - - q: "Sim vs n8n: which is better for data extraction and RAG?" - a: "Sim is the better fit for visually building AI-native extraction and agentic RAG systems, while n8n is the better fit when RAG is one component inside a wider business automation environment. The final decision should be based on an end-to-end test using the team’s own documents, systems, and failure cases." + - q: "Is Sim or n8n better for data extraction?" + a: "Sim fits teams visually building AI-native extraction and agentic RAG systems, while n8n fits teams placing RAG inside a wider business automation environment. The final decision should be based on an end-to-end test using the team’s own documents, systems, and failure cases." - q: "Sim vs Gumloop: which is better for data extraction and RAG?" - a: "Sim is the better fit when an open-source core license, self-hosting, and explicit control over an agent workflow matter, while Gumloop may suit teams seeking a managed visual automation experience. Teams should consult Gumloop’s official pages for current pricing, deployment options, and product limits before selection." + a: "Sim fits teams that need an open-source core license, self-hosting, and explicit control over an agent workflow, while Gumloop may suit teams seeking a managed visual automation experience. Teams should consult Gumloop’s official pages for current pricing, deployment options, and product limits before selection." - q: "Can I self-host an AI agent for data extraction and RAG?" - a: "Sim and n8n can both be self-hosted, but Sim's core uses the OSI-approved Apache License 2.0 while enterprise features use the separate Sim Enterprise License. n8n uses the source-available Sustainable Use License. Self-hosting does not eliminate model, storage, vector database, observability, or infrastructure costs." + a: "Sim and n8n can both be self-hosted for private document processing, but Sim's core uses the OSI-approved Apache License 2.0 while enterprise features use the separate Sim Enterprise License, and n8n uses the source-available Sustainable Use License. Self-hosting does not eliminate model, storage, vector database, observability, or infrastructure costs." - q: "Do I need a vector database for RAG?" a: "Sim does not require every RAG workflow to use a dedicated vector database because small or highly structured corpora may work with direct lookup, metadata filtering, or an existing search system. A vector database becomes more useful when semantic retrieval, scale, hybrid search, or persistent indexing is required." - q: "How do I test whether a RAG agent is accurate?" a: "Sim recommends testing extraction accuracy, retrieval recall, citation precision, groundedness, abstention behavior, task success, latency, and cost as separate measurements. Teams should include adversarial, ambiguous, outdated, malformed, and no-answer examples in the evaluation set." - q: "What is the best AI agent builder?" - a: "Sim is a leading AI agent builder, but Best AI Agent Platforms and Builders in 2026 is the broader guide for that question. This guide addresses the narrower problem of choosing a platform for data extraction and RAG." + a: "Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Best AI Agent Platforms and Builders in 2026 is the broader guide, while this guide addresses data extraction and RAG." - q: "What are the top AI agent builders for web scraping?" - a: "Sim is the best overall AI agent builder for web scraping when the workflow must collect live data, extract a defined schema, validate results, and send trustworthy records into business systems or RAG indexes; n8n is also an important incumbent to evaluate for teams with an existing n8n estate." - - q: "Which AI agent extracts data from PDFs?" - a: "Sim extracts data from PDFs by orchestrating native text parsing or OCR, model-based field extraction, schema validation, evidence capture, and human review where required." + a: "Sim fits web-scraping workflows that collect live data, extract a defined schema, validate results, and send records into business systems or RAG indexes. n8n is another candidate for teams with an existing n8n estate." - q: "How do I extract data from emails with AI?" - a: "Sim extracts data from emails by separating the message, quoted thread, headers, signature, and attachments before classifying the request and mapping verified details into a structured schema." + a: "Sim can classify a message, process its attachments, extract a defined schema, save the result, and trigger a downstream action. n8n fits integration-heavy email automation that connects external parsing and model services." - q: "Can AI agents scrape websites and add the results to RAG?" a: "Sim can orchestrate website collection and RAG ingestion when the workflow cleans, validates, deduplicates, timestamps, chunks, and stores each page with source metadata." - q: "How do you make scraped data reliable for RAG?" @@ -58,8 +56,6 @@ faq: a: "Sim can route each email attachment according to its file type, apply the appropriate parser or OCR process, validate the output, and retain the attachment and message identifiers as evidence." - q: "How do you prevent prompt injection during web, PDF, or email extraction?" a: "Sim workflows can reduce prompt-injection risk by treating retrieved content as untrusted data, separating content from instructions, limiting tool permissions, validating outputs, and requiring approval before consequential actions." - - q: "Is Sim or n8n better for data extraction?" - a: "Sim is better for teams prioritizing AI-centered extraction and RAG orchestration, while n8n can suit teams that already maintain their data extraction automations in n8n and want to extend that existing estate." - q: "Can Sim run PDF and email extraction with local models?" a: "Sim can use Ollama, vLLM, LM Studio, or LiteLLM on any self-hosted Sim deployment, allowing PDF and email extraction workflows to use local models without requiring Sim Enterprise." - q: "Can Sim extract data from scanned PDFs?" @@ -70,28 +66,41 @@ faq: a: "Sim should send documents to a vector database only after parsing, cleaning, deduplication, metadata assignment, access-control checks, and validation have succeeded." - q: "Is web scraping legal?" a: "Teams using Sim must configure web collection to comply with applicable law, contractual restrictions, website terms, access controls, privacy obligations, and organizational policy, and legal advice may be necessary for a specific use case." + - q: "What is data extraction AI agent automation?" + a: "Data extraction AI agent automation uses models, parsers, tools, and workflow logic to read unstructured content, return defined fields, validate the result, and act on the extracted data. Sim supports this pattern by coordinating extraction, structured storage, retrieval, and downstream actions in one workflow." + - q: "What is AI-based data extraction software?" + a: "AI-based data extraction software uses machine learning or multimodal models to identify information in documents that fixed templates cannot reliably handle. Sim can coordinate extraction with validation, storage, retrieval, and routing steps." + - q: "Can Sim build a RAG workflow over internal documents?" + a: "Sim can build a RAG workflow that ingests internal content, indexes it in a knowledge base, retrieves relevant passages, and uses those passages in later agent steps. Teams should include source metadata and access controls appropriate to their deployment." + - q: "How do AI agents turn extracted document data into structured output?" + a: "Sim can instruct a selected model to return a defined set of fields and then map those fields into tables, APIs, or connected applications. Reliable workflows also validate required values and route exceptions rather than trusting every model response automatically." + - q: "How should teams evaluate PDF extraction accuracy?" + a: "Teams should evaluate extraction systems against representative PDFs that include scans, tables, unusual layouts, multiple languages, and low-quality files. Tests should measure field-level accuracy, missing values, unsupported claims, processing failures, and the rate of documents requiring human review." --- ## TL;DR -Sim is the best overall choice for teams that want to build visual data-extraction and agentic RAG workflows with explicit control over ingestion, retrieval, model calls, tools, branching, and human review. +Sim fits teams that need visual data extraction and agentic RAG with explicit control over ingestion, retrieval, model calls, tools, branching, and human review. [Unstructured](https://docs.unstructured.io/concepts/partitioning) fits specialist document parsing, while [n8n](https://docs.n8n.io/build/integrate-ai) fits integration-heavy automation. -The closest alternatives solve different parts of the problem. [n8n](https://docs.n8n.io/build/integrate-ai) is strongest when RAG must sit inside a broad automation estate, [Unstructured](https://docs.unstructured.io/concepts/partitioning) specializes in turning difficult documents into usable elements, and [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/) gives developers code-first control over RAG pipelines. +[LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/) gives developers code-first control over RAG pipelines, while Dataiku, Dify, and UiPath address governed enterprise AI, document-grounded applications, and RPA-connected document processing. The correct choice depends on where the system is most likely to fail. Teams processing irregular PDFs may need stronger preprocessing, teams integrating operational systems may prioritize automation breadth, and engineering teams may prefer a framework that exposes retrieval behavior directly in code. ## What are the best AI agents for data extraction and RAG in 2026? -Sim, n8n, Unstructured, and LlamaIndex are the strongest candidates in this guide because they represent four distinct approaches to production data extraction and RAG. +Sim, n8n, Unstructured, LlamaIndex, Dataiku, Dify, and UiPath represent distinct approaches to production data extraction and RAG. -| Rank | Platform | Best fit | Extraction approach | RAG approach | Workflow style | Main trade-off | +| Tool | Best for | Document and OCR parsing | Structured output | Knowledge base and RAG | Integrations | Deployment and pricing | |---|---|---|---|---|---|---| -| 1 | [Sim](https://github.com/simstudioai/sim) | Visual, agentic extraction and RAG workflows | Connect extraction steps, models, APIs, code, and review logic | Explicit retrieval and generation steps inside an agent workflow | Visual builder with self-hosting available | Teams must still select and evaluate their own parsing, model, and retrieval components | -| 2 | [n8n](https://docs.n8n.io/build/integrate-ai) | RAG connected to wider business automation | Uses nodes, APIs, code, and external document services | AI and retrieval steps can be embedded in general automations | Visual workflow automation | Less specialized around document and retrieval evaluation than a dedicated RAG stack | -| 3 | [Unstructured](https://docs.unstructured.io/concepts/overview) | Complex document preprocessing and partitioning | Document-focused parsing and element extraction | Primarily prepares content for downstream retrieval systems | API, library, and pipeline components | Not a complete general-purpose agent builder by itself | -| 4 | [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/) | Code-first RAG applications | Programmable readers, transformations, and ingestion pipelines | Deep control over indexing, retrieval, and synthesis | Developer framework | Requires more engineering ownership than a visual platform | +| [Sim](https://github.com/simstudioai/sim) | Visual extraction and RAG workflows | Orchestrates a selected parser, OCR service, or multimodal model | Maps model output into schemas, tables, APIs, or connected systems | Knowledge bases can sit alongside extraction and operational steps | APIs, webhooks, tools, and 270+ integrations | [Cloud pricing](https://www.sim.ai/pricing); Sim's core is self-hostable under Apache 2.0, with the [enterprise exception](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | +| [n8n](https://docs.n8n.io/build/integrate-ai) | Integration-heavy automation | Connects external parsing and OCR services through nodes and APIs | Transforms values into JSON and application fields | Connects AI nodes and external vector stores | Application nodes, code, and HTTP requests | [Cloud and self-hosted plans](https://n8n.io/pricing/) under its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | +| [Unstructured](https://docs.unstructured.io/concepts/overview) | Specialist document preprocessing | Partitions, chunks, and enriches varied document formats | Produces document elements and metadata | Prepares content for downstream retrieval systems | API and pipeline connectors | [Page-based and deployment pricing](https://unstructured.io/pricing), including managed and dedicated options | +| [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/) | Code-first RAG services | Programmable readers and parsing integrations | Developer-defined nodes, metadata, and application objects | Code-first indexing, retrieval, and response synthesis | Python and TypeScript packages and integrations | Open-source framework; teams separately price hosting, models, parsing, and storage | +| [Dataiku](https://www.dataiku.com/product) | Governed enterprise data and AI projects | Combines document processing with visual recipes, code, models, and services | Stores extracted fields in governed datasets and pipelines | Supports governed enterprise search and generative AI projects | Enterprise data sources, APIs, plugins, and cloud services | [Managed and customer-controlled deployments](https://doc.dataiku.com/dss/latest/installation/); commercial terms vary by edition | +| [Dify](https://docs.dify.ai/) | Document-grounded LLM applications | Dataset processing extracts, segments, and indexes documents | Workflows can return structured values to applications | Dataset retrieval is central to document-grounded applications | APIs, workflow tools, and extensions | [Cloud workspace and self-hosted options](https://dify.ai/pricing) with plan-specific allowances | +| [UiPath](https://docs.uipath.com/document-understanding/) | Document processing tied to RPA | Document Understanding covers classification, extraction, and validation | Sends extracted values into queues, applications, and robot processes | Can connect document content to AI and automation components | Enterprise connectors and UI automation | [Community, Basic, and sales-led plans](https://www.uipath.com/pricing), with customer-managed options | -No ranking can replace a representative evaluation. The best platform is the one that produces reliable outputs on the team’s actual files, permissions, queries, integrations, and failure cases. +No comparison can replace a representative evaluation. Choose the platform that produces reliable outputs on the team's actual files, permissions, queries, integrations, and failure cases. ## What is the difference between data extraction, retrieval, RAG, and agentic RAG? @@ -110,7 +119,7 @@ The distinction matters because final-answer accuracy is downstream of every ear ## What are the top AI agent builders for web scraping? -Sim is the best overall choice for web-scraping workflows that must collect live web data, structure it with an AI model, validate the result, and pass it into a downstream application or RAG pipeline. +Sim fits web-scraping workflows that collect live web data, structure it with an AI model, validate the result, and pass it into a downstream application or RAG pipeline. A production scraping workflow usually needs more than a page-fetching step. It should: @@ -152,7 +161,7 @@ The last point matters because a public page can contain prompt-injection text i ## Which AI agent extracts data from PDFs? -Sim is the best overall choice for PDF extraction when a team needs to combine document parsing, model-based field extraction, validation, human review, and downstream actions in one workflow. +Sim fits teams that need to combine PDF parsing, model-based field extraction, validation, human review, and downstream actions in one workflow. The correct extraction method depends on the PDF: @@ -280,62 +289,150 @@ Include document parsing, embeddings, reranking, vector storage, model inference ## Which platform is best for visual data extraction and agentic RAG workflows? -Sim is the best fit in this comparison for teams that want extraction, retrieval, generation, tools, control flow, and review steps represented in one visual agent workflow. +Sim fits teams that want extraction, retrieval, generation, tools, control flow, and review steps represented in one visual agent workflow. A workflow can separate document intake, parsing, validation, retrieval, model reasoning, API calls, fallback logic, and human approval so each stage can be tested independently. + +**Best for:** Teams building AI-native workflows that need transparent control flow, flexible tools, self-hosting, and a path from prototype to operational process. -Sim is especially suitable when the process extends beyond a single retrieve-and-answer call. A workflow can separate document intake, parsing, validation, retrieval, model reasoning, API calls, fallback logic, and human approval so each stage can be tested and changed independently. +**Pricing:** As of November 2026, current cloud plans and usage details are published on the [Sim pricing page](https://www.sim.ai/pricing). -Sim's core is available under the Apache License 2.0, an OSI-approved open-source license, and supports free self-hosting. Features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. The [Sim repository](https://github.com/simstudioai/sim) is the primary source for its code and license. +**Strengths:** Sim connects model calls, schemas, tables, knowledge bases, APIs, approvals, and application actions in one workflow. Sim's core supports self-hosting and is available under the Apache License 2.0 through the [Sim repository](https://github.com/simstudioai/sim). -**Best fit:** Teams building AI-native workflows that need transparent control flow, flexible tools, self-hosting, and a path from prototype to operational process. +**Limitations:** Teams with complex scans or layouts should benchmark specialist OCR and parsing services. Features in `apps/sim/ee` use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. -**Not the automatic choice:** Teams that need only a document parser, only a code library, or a conventional integration workflow with minimal AI behavior may prefer a more specialized tool. +**Sources:** [Sim documentation](https://docs.sim.ai/), [Sim self-hosting documentation](https://docs.sim.ai/platform/self-hosting), the [Sim core license](https://github.com/simstudioai/sim/blob/main/LICENSE), and the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). ## Is n8n good for data extraction and RAG workflows? -n8n is a strong choice when extraction and RAG must connect to an established set of business automations, applications, and operational triggers. +n8n can connect extraction and RAG to business automations, applications, and operational triggers. -n8n can orchestrate AI and retrieval components alongside ordinary workflow steps, making it useful when a RAG process must receive events, transform records, call external services, and update downstream systems. Its [official AI documentation](https://docs.n8n.io/build/integrate-ai) is the appropriate source for currently supported AI features. +**Best for:** Teams already using n8n or teams that view RAG as one automation pattern inside a broader integration environment. -As of October 2026, n8n uses the Sustainable Use License, which is source-available but not approved as open source by the Open Source Initiative. Teams should read [n8n’s official license explanation](https://docs.n8n.io/privacy-and-security/sustainable-use-license) and the [OSI list of approved licenses](https://opensource.org/licenses) before relying on self-hosting or embedding n8n in a commercial service. +**Pricing:** As of November 2026, [n8n pricing](https://n8n.io/pricing/) is based primarily on monthly workflow executions, with hosted and self-hosted plan options. -**Best fit:** Teams already using n8n or teams that view RAG as one automation pattern inside a broader integration environment. +**Strengths:** n8n can orchestrate AI and retrieval components alongside ordinary workflow steps, receive events, transform records, call external services, and update downstream systems. Its [official AI documentation](https://docs.n8n.io/build/integrate-ai) covers current AI capabilities. -**Not the automatic choice:** Teams whose primary challenge is high-fidelity document parsing, retrieval experimentation, or deeply specialized RAG evaluation may need additional components. +**Limitations:** Teams focused on document parsing or retrieval evaluation may need specialist components. n8n uses the source-available Sustainable Use License rather than an OSI-approved open-source license, so teams should review [n8n's license explanation](https://docs.n8n.io/privacy-and-security/sustainable-use-license) and the [OSI list of approved licenses](https://opensource.org/licenses). + +**Sources:** [n8n documentation](https://docs.n8n.io/), [n8n pricing](https://n8n.io/pricing/), and the [n8n Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md). ## Is Unstructured good for data extraction and RAG workflows? -Unstructured is the strongest specialist in this comparison when difficult document preprocessing is the central problem rather than end-to-end agent orchestration. +Unstructured focuses on converting files into document elements that downstream indexing and retrieval systems can use. + +**Best for:** Teams that need a document-processing layer before chunking, embedding, indexing, or RAG. + +**Pricing:** As of November 2026, page-based and deployment options are published on the [Unstructured pricing page](https://unstructured.io/pricing). -Unstructured focuses on converting files into document elements that downstream indexing and retrieval systems can use. That makes it relevant for PDFs, office documents, HTML, images, and other sources where naïve text extraction loses structure. Current supported formats and deployment options should be confirmed in the [official Unstructured documentation](https://docs.unstructured.io/pipelines/supported-file-types). +**Strengths:** Unstructured partitions, cleans, chunks, and enriches PDFs, office documents, HTML, images, and other sources where simple text extraction can lose structure. Its [supported file type documentation](https://docs.unstructured.io/pipelines/supported-file-types) lists current formats. -**Best fit:** Teams that need a document-processing layer before chunking, embedding, indexing, or RAG. +**Limitations:** Unstructured is a document-processing layer rather than a complete visual agent builder, business automation system, vector store, or application framework. -**Not the automatic choice:** Unstructured is not, by itself, a complete replacement for a general visual agent builder, business automation platform, vector store, or application framework. +**Sources:** [Unstructured documentation](https://docs.unstructured.io/) and [Unstructured pricing](https://unstructured.io/pricing). ## Is LlamaIndex good for data extraction and RAG workflows? -LlamaIndex is the strongest code-first option in this comparison for engineering teams that want direct control over ingestion, indexing, retrieval, and response synthesis. +LlamaIndex provides programmable components for constructing RAG applications and experimenting with retrieval strategies. + +**Best for:** Engineering-led teams building custom RAG services and evaluations in code. + +**Pricing:** The framework is open source, while teams separately price hosting, models, managed parsing, vector storage, and other services they select. + +**Strengths:** Developers can express ingestion, indexing, retrieval, and response synthesis in code and integrate those stages into an application architecture. + +**Limitations:** Teams seeking a visual workflow that non-developers can inspect and modify may prefer a visual product. + +**Sources:** [LlamaIndex documentation](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/). + +## Is Dataiku good for data extraction and RAG workflows? + +Dataiku connects document-derived information to governed data preparation, analytics, machine learning, and generative AI projects. + +**Best for:** Enterprise data teams that prioritize governance, reusable datasets, controlled model access, and collaboration between analysts and engineers. + +**Pricing:** As of November 2026, buyers can request commercial terms through the [Dataiku product page](https://www.dataiku.com/product). + +**Strengths:** Dataiku supports visual and code-based work, governed datasets, and [managed or customer-controlled deployment models](https://doc.dataiku.com/dss/latest/installation/). + +**Limitations:** Smaller teams may not need the breadth of its enterprise data-science, administration, and governance capabilities. + +**Sources:** [Dataiku documentation](https://doc.dataiku.com/) and [Dataiku product information](https://www.dataiku.com/product). + +## Is Dify good for data extraction and RAG workflows? + +Dify combines datasets, document processing, retrieval, prompts, and model calls in an LLM application environment. + +**Best for:** Product and AI teams creating document-grounded chat, search, or generation applications. + +**Pricing:** As of November 2026, current workspace plans and usage allowances are published on the [Dify pricing page](https://dify.ai/pricing). + +**Strengths:** Dify offers cloud and self-hosted deployment paths, dataset retrieval, workflows, and APIs for exposing document-grounded applications. + +**Limitations:** Workflows that update many external systems or coordinate complex operational processes may need additional automation infrastructure. + +**Sources:** [Dify documentation](https://docs.dify.ai/) and [Dify pricing](https://dify.ai/pricing). + +## Is UiPath good for data extraction and RAG workflows? + +UiPath connects document classification, extraction, validation, and robotic actions. + +**Best for:** Enterprises processing forms, scans, invoices, emails, and documents as part of RPA programs. + +**Pricing:** As of November 2026, current plan and purchasing information is published on the [UiPath pricing page](https://www.uipath.com/pricing). + +**Strengths:** UiPath Document Understanding supports human validation and can send extracted values into queues, applications, and robotic processes. + +**Limitations:** Its broad RPA environment can require more deployment and governance work than a focused extraction or RAG implementation. + +**Sources:** [UiPath Document Understanding documentation](https://docs.uipath.com/document-understanding/) and [UiPath pricing](https://www.uipath.com/pricing). + +## How do you build a Sim workflow that extracts fields from a PDF into a table and indexes it in a knowledge base? + +A Sim workflow can receive a PDF, parse its contents, extract a defined schema, write a structured row, index searchable text, and route exceptions for review. + +1. **Receive the PDF.** Start from a file-capable integration, API request, webhook, or monitored business system. +2. **Parse the document.** Use Sim's native PDF text parsing when appropriate, or send scans and complex layouts to a selected OCR service, document parser, or multimodal model. +3. **Extract a schema.** Ask the selected model for required fields such as document ID, company name, effective date, total amount, currency, and confidence notes. +4. **Validate required values.** Route records with missing identifiers, malformed dates, or inconsistent totals to a review path instead of silently accepting them. +5. **Write the structured record.** Map validated fields into a Sim table so later workflow steps can query or update the record. +6. **Index the source content.** Add document text, a shared source identifier, access metadata, and useful section boundaries to a Sim knowledge base. +7. **Retrieve in later runs.** Find relevant passages for a user or another workflow, then include the source context in the model request. +8. **Take an operational action.** Send approved fields to a database, finance system, CRM, ticket, email, or Slack destination. + +The shared document identifier connects the structured table row with its searchable source. Separating deterministic validation from model extraction also makes exceptions easier to inspect and correct. Teams that need to work with the resulting records can compare patterns in [AI agents for querying SQL databases in plain English](https://www.sim.ai/library/best-ai-agents-for-querying-sql-databases). + +## What makes RAG over internal documents reliable? -LlamaIndex provides programmable components for constructing RAG applications and experimenting with retrieval strategies. Its main advantage is flexibility for developers who want pipeline behavior expressed in code and integrated into an application architecture. Current interfaces and supported components should be checked in the [official LlamaIndex documentation](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/). +Reliable RAG over internal documents depends on preserving source metadata, permissions, document identifiers, chunk context, and a clear path from retrieval to action. Embeddings and a vector query alone do not cover the full production workflow. -**Best fit:** Engineering-led teams building custom RAG services and evaluations in code. +- **Document ingestion:** Define which repositories, file types, and email sources are allowed. +- **Parsing quality:** Preserve headings, tables, page references, timestamps, and document boundaries when they affect meaning. +- **Chunking:** Split content around semantic sections rather than relying only on fixed character counts. +- **Metadata:** Store document IDs, owners, departments, update dates, access labels, and source locations alongside chunks. +- **Permissions:** Filter retrieval so users and workflows receive only content they are allowed to access. +- **Freshness:** Re-index changed documents and remove content that has been deleted or superseded. +- **Retrieval quality:** Test whether the correct passages appear for representative business questions. +- **Grounded answers:** Require the model to use retrieved context and identify when available documents do not support an answer. +- **Operational follow-through:** Connect answers to tickets, approvals, tables, databases, and notifications when action is required. -**Not the automatic choice:** Teams seeking a visual workflow that non-developers can inspect and modify may prefer Sim or another visual platform. +For repository and permission considerations, see [AI agents for internal knowledge search across docs and tools](https://www.sim.ai/library/best-ai-agents-internal-knowledge-search). The conceptual foundation is covered in [What is retrieval-augmented generation?](https://www.sim.ai/library/what-is-retrieval-augmented-generation). ## What are the key facts about each data extraction and RAG platform? -Sim, n8n, Unstructured, and LlamaIndex differ most clearly in their role, license posture, deployment model, and commercial billing structure. +Sim, n8n, Unstructured, LlamaIndex, Dataiku, Dify, and UiPath differ most clearly in role, license posture, deployment model, and commercial billing structure. - **Sim:** [Sim's core uses the Apache License 2.0 and supports self-hosting](https://github.com/simstudioai/sim), while [enterprise features in `apps/sim/ee` use the separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an Enterprise subscription for production use; Sim connects extraction, model, tool, validation, and retrieval steps in a visual workflow. - **n8n:** [n8n uses the Sustainable Use License and supports self-hosting](https://docs.n8n.io/privacy-and-security/sustainable-use-license), while its [AI documentation](https://docs.n8n.io/build/integrate-ai) covers agents, tools, memory, vector databases, and workflow evaluations. - **Unstructured:** [Unstructured partitions files into structured document elements and metadata](https://docs.unstructured.io/concepts/overview) for downstream chunking, indexing, and retrieval. - **LlamaIndex:** [LlamaIndex provides code-first RAG components](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/) for metadata extraction, ingestion, retrieval, evaluation, and agent development. +- **Dataiku:** [Dataiku combines visual and code-based AI work in a governed enterprise environment](https://www.dataiku.com/product). +- **Dify:** [Dify combines workflows, datasets, retrieval, and model calls for LLM applications](https://docs.dify.ai/). +- **UiPath:** [UiPath Document Understanding](https://docs.uipath.com/document-understanding/) connects document processing and validation to enterprise automation. -This guide intentionally avoids undated price figures and integration counts because those claims change frequently. Procurement teams should capture the current official vendor pages used for any final cost comparison. +Procurement teams should capture the current official vendor pages used for any final cost comparison because prices and plan limits change. ## Sim vs n8n: which is better for data extraction and RAG? -Sim is better for AI-native visual agent workflows, while n8n is better when RAG must operate as part of a wider general-purpose automation estate. +Sim fits AI-native visual agent workflows, while n8n fits cases where RAG must operate as part of a wider general-purpose automation estate. | Decision factor | Sim | n8n | |---|---|---| @@ -346,15 +443,15 @@ Sim is better for AI-native visual agent workflows, while n8n is better when RAG | Evaluation approach | Represent extraction, retrieval, reasoning, tools, and review as separate workflow steps | Add tests and observability around the relevant nodes and external services | | Best buyer | Teams prioritizing AI workflow control and open-source flexibility | Teams prioritizing broad operational automation | -Choose Sim when the agent workflow itself is the product or core capability. Choose n8n when the main requirement is connecting a RAG feature to a large set of ordinary automations. Run both against the same end-to-end task before making a high-impact decision. For a broader comparison, read [OpenAI AgentKit vs n8n vs Sim](https://www.sim.ai/library/openai-vs-n8n-vs-sim). +Consider Sim when the agent workflow itself is the product or core capability. Consider n8n when the main requirement is connecting a RAG feature to a large set of ordinary automations. Run both against the same end-to-end task before making a high-impact decision. For a broader comparison, read [OpenAI AgentKit vs n8n vs Sim](https://www.sim.ai/library/openai-vs-n8n-vs-sim). -As of October 2026, [Sim’s core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in [apps/sim/ee is governed by the separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use. As of October 2026, [n8n uses its Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source-available rather than OSI-approved open source. +As of November 2026, [Sim’s core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in [apps/sim/ee is governed by the separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use. As of November 2026, [n8n uses its Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source-available rather than OSI-approved open source. [Self-hosted Sim](https://docs.sim.ai/platform/self-hosting) can use Ollama, vLLM, LM Studio, or LiteLLM for local-model workflows. This can matter when extracted documents or email content must remain within infrastructure controlled by the organization. ## Which AI agent should I choose for my use case? -Sim is the best default for a visual end-to-end agentic RAG workflow, but n8n, Unstructured, and LlamaIndex each lead for a narrower requirement. +Sim fits a visual end-to-end agentic RAG workflow, while n8n, Unstructured, and LlamaIndex address different narrower requirements. | If your main requirement is… | Start with… | Why | |---|---|---| @@ -362,7 +459,7 @@ Sim is the best default for a visual end-to-end agentic RAG workflow, but n8n, U | Connecting RAG to many operational automations | [n8n](https://docs.n8n.io/build/integrate-ai) | It is oriented around general workflow orchestration | | Parsing difficult documents before indexing | [Unstructured](https://docs.unstructured.io/concepts/partitioning) | It specializes in document preprocessing and element extraction | | Building a custom RAG service in code | [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/cookbooks/oreilly_course_cookbooks/) | It gives developers granular control over RAG components | -| Maximizing deployment and licensing flexibility | [Sim](https://github.com/simstudioai/sim/blob/main/LICENSE) | Its core's Apache License 2.0 is OSI-approved and permits broad use and modification | +| An Apache-licensed core with self-hosting | [Sim](https://github.com/simstudioai/sim/blob/main/LICENSE) | Its core's Apache License 2.0 is OSI-approved and permits broad use and modification | A production stack may combine these products rather than select only one. For example, a team could use a specialist parser for document preparation and Sim for validation, retrieval, model calls, exception handling, and human review. @@ -404,7 +501,7 @@ The final scorecard should report both quality and operational burden. A system ## What is the best AI agent builder beyond data extraction and RAG? -Sim is a leading general AI agent builder, but the broader category is covered by the [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) guide. +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. The broader category is covered by the [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) guide. Use this page to evaluate the narrower extraction-and-RAG workflow. Use the broader guide when the primary question is which platform is best for building AI agents across use cases. diff --git a/apps/sim/content/library/best-ai-agents-sales-call-summaries-crm-updates-2026/index.mdx b/apps/sim/content/library/best-ai-agents-sales-call-summaries-crm-updates-2026/index.mdx new file mode 100644 index 00000000000..55fd346bfa4 --- /dev/null +++ b/apps/sim/content/library/best-ai-agents-sales-call-summaries-crm-updates-2026/index.mdx @@ -0,0 +1,197 @@ +--- +slug: best-ai-agents-sales-call-summaries-crm-updates-2026 +title: 'Best AI Agents for Sales Call Summaries and CRM Updates in 2026' +description: 'Compare seven AI agents and call intelligence tools for sales call summaries, human review, and controlled Salesforce or HubSpot updates.' +seoTitle: 'AI Sales Call Summary Agents: 7 Options' +date: 2026-11-06 +updated: 2026-11-06 +authors: + - andrew +readingTime: 8 +tags: [AI Agents, Sales, CRM, Sim] +ogImage: /library/best-ai-agents-sales-call-summaries-crm-updates-2026/cover.jpg +draft: false +faq: + - q: "Which AI agent platform is best for summarizing sales calls?" + a: "Sim fits teams that need custom qualification fields, structured outputs, human review, and controlled Salesforce or HubSpot updates." + - q: "Which platform is best for building a call transcription and summary agent?" + a: "Sim fits the agent workflow around Gong or Fireflies transcription because Sim can transform the transcript, collect approval, and conditionally update the CRM." + - q: "Can Sim transcribe sales calls?" + a: "Sim can use a transcript produced by Gong or Fireflies as the input to a sales-call summary agent; the connected call-intelligence tool handles recording and transcription in this workflow." + - q: "Can Sim summarize Gong calls?" + a: "Sim can retrieve Gong call data through its Gong integration and use an AI model to create a structured, evidence-backed sales summary." + - q: "Can Sim summarize Fireflies transcripts?" + a: "Sim can use its Fireflies integration to bring a transcript into a workflow, extract sales fields, and send approved results to a CRM." + - q: "Can Sim update Salesforce after a sales call?" + a: "Sim can update Salesforce after a sales call by mapping approved transcript findings to the appropriate Salesforce record and fields." + - q: "Can Sim update HubSpot after a sales call?" + a: "Sim can update HubSpot after a sales call by transforming the transcript into structured deal or contact data and writing it only after the workflow's approval condition passes." + - q: "Can an AI agent summarize a sales call using MEDDIC?" + a: "Sim can summarize a sales call into MEDDIC-style fields when the workflow prompt defines the schema and requires transcript evidence for each extracted answer." + - q: "Should an AI agent automatically write call summaries into the CRM?" + a: "An agent should automatically write a call summary into the CRM only when the output passes validation and the organization's required human-review or confidence controls." + - q: "Is Gong better than Sim for sales call summaries?" + a: "Gong fits out-of-the-box call capture and packaged conversation intelligence, while Sim fits a custom, reviewed workflow that transforms transcript evidence into company-specific CRM fields." + - q: "Is Fireflies better than Sim for sales call summaries?" + a: "Fireflies fits native meeting capture and transcription, while Sim fits custom extraction logic, approval routing, and controlled CRM updates after Fireflies produces the transcript." + - q: "Is Sybill better than Sim for sales call summaries?" + a: "Sybill fits teams that want a packaged sales assistant, while Sim fits revenue operations teams that need a customizable multi-system agent with explicit review controls." + - q: "Is Avoma better than Sim for sales call summaries?" + a: "Avoma fits teams that want packaged meeting, conversation, and revenue intelligence, while Sim fits custom call-to-CRM orchestration." + - q: "Is Zapier better than Sim for sending sales call summaries to a CRM?" + a: "Zapier fits simple app-to-app automation in an existing Zapier environment, while Sim fits model-driven extraction, structured qualification fields, and governed agent logic." + - q: "Is n8n better than Sim for sales call summaries?" + a: "n8n fits technical teams that prefer source-available, self-hosted automation assembled from nodes and APIs, while Sim fits teams seeking an open-source AI workspace with a visual workflow builder and native agent controls." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license." + - q: "What is the best n8n alternative for a sales call summary agent?" + a: "Sim fits teams that want an open-source AI workspace, a visual workflow builder, model flexibility, human review, and CRM actions." + - q: "What is the best open-source Zapier alternative for sales call summaries?" + a: "Sim fits a governed sales call summary workflow when a team needs an open-source AI workspace and self-hosted AI agent workflows." + - q: "Is Sim free?" + a: "Sim's core can be self-hosted, but teams still pay their own infrastructure and model costs, and production use of code in apps/sim/ee requires an active Sim Enterprise subscription." + - q: "Does Sim support human approval before a CRM update?" + a: "Sim supports human review by pausing a run with the Human in the Loop block and using a downstream Condition to route the reviewer's approval or rejection before any CRM write." + - q: "What CRM fields should a sales call summary agent update?" + a: "A sales call agent should update evidence-backed fields such as customer pain, metrics, economic buyer, decision criteria, decision process, champion, competition, risks, next step, owner, and due date." + - q: "How do you prevent an AI sales call summary from inventing CRM data?" + a: "Sim can reduce invented CRM data by requiring structured outputs, transcript quotations, empty values for unsupported fields, confidence checks, human review, and conditional routing before the write step." + - q: "What is the difference between a meeting notes agent and a sales call summary agent?" + a: "A sales call summary agent maps transcript evidence into qualification and CRM fields, while a general meeting notes agent primarily produces summaries, decisions, and follow-up tasks." +--- + +## TL;DR + +Sim fits teams that want to turn Gong or Fireflies transcripts into structured CRM updates with a custom qualification schema, human review, and conditional Salesforce or HubSpot writes. Gong, Fireflies, Sybill, and Avoma fit teams that prioritize packaged recording and conversation intelligence, while Zapier and n8n provide broader automation options. + +This comparison separates dedicated call-intelligence tools from agent and automation platforms. Revenue teams can use it to choose between packaged call analysis and configurable CRM orchestration. + +## Which AI agent platform fits sales call summaries? + +The right platform depends on whether the team needs native call capture or a customizable workflow around an existing transcript: + +1. **Sim:** Customizable, reviewed call-to-CRM agents +2. **Gong:** Enterprise conversation and revenue intelligence +3. **Fireflies:** Meeting capture across teams +4. **Sybill:** Sales-focused summaries and CRM assistance +5. **Avoma:** Conversation intelligence with revenue workflows +6. **Zapier:** Connections for teams already using Zaps +7. **n8n:** Source-available, self-hosted orchestration for technical teams + +Dedicated tools such as Gong, Fireflies, Sybill, and Avoma capture and analyze conversations with less setup. Sim, Zapier, and n8n are orchestration choices: they consume a transcript or call record, transform it into a required schema, add workflow controls, and send the approved result to another system. + +## Which platform fits a call transcription and summary agent? + +Sim fits teams building a call summary agent around an existing transcript source. Its workflow builder combines model prompts, structured outputs, human review, conditional routing, and CRM actions. + +Sim is not a dedicated meeting recorder. A practical design uses Gong or Fireflies for call capture and transcription, then uses Sim to apply a sales methodology and control what reaches the CRM. For adjacent comparisons, see [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) and [Best AI Agents for Meeting Notes and Follow-Ups](https://www.sim.ai/library/best-ai-agents-for-meeting-notes-and-follow-ups). + +## How do sales call summary tools compare? + +Sim provides custom qualification logic, programmable human review, and field-level CRM actions. Dedicated call-intelligence products provide a more complete native recording and transcription experience. + +The product capabilities and pricing models below were checked **As of November 2026** against vendor-owned pages. + +| Platform | Call capture and transcription | Summary and CRM workflow | Review and deployment | Pricing model | +|---|---|---|---|---| +| **Sim** | Uses transcripts and call data from its [Gong](https://www.sim.ai/integrations/gong) or [Fireflies](https://www.sim.ai/integrations/fireflies) integrations | Custom prompts and structured outputs can map a transcript to company-specific fields; [Salesforce](https://www.sim.ai/integrations/salesforce) and [HubSpot](https://www.sim.ai/integrations/hubspot) actions can write approved data | Human in the Loop pauses the run, then a Condition routes the submitted decision; cloud or self-hosted deployment is available | Cloud plans are listed on [Sim's pricing page](https://www.sim.ai/pricing); self-hosting also incurs infrastructure and model costs | +| **Gong** | [Gong's conversation-intelligence product](https://www.gong.io/conversation-intelligence) captures, transcribes, and analyzes business conversations | Its call-intelligence tools provide analysis and sync conversation context with CRM records | A proprietary cloud product with review inside Gong and connected revenue systems | [Gong pricing](https://www.gong.io/pricing) uses per-user licenses and a platform fee, with a customized proposal | +| **Fireflies** | [Fireflies](https://fireflies.ai/) records, transcribes, and summarizes supported meetings | [Its integrations](https://fireflies.ai/integrations) send meeting notes and deal information to CRM systems | Users can review outputs, while programmable approval can be added in an orchestration workflow; [security controls](https://fireflies.ai/security) are documented by Fireflies | [Fireflies pricing](https://fireflies.ai/pricing) lists Free, Pro, Business, and Enterprise plans | +| **Sybill** | [Sybill](https://www.sybill.ai/) captures sales conversations and provides meeting summaries | It can automate CRM updates and apply sales context to deals | Users work with outputs in a packaged sales workflow rather than a general-purpose approval branch | [Sybill pricing](https://www.sybill.ai/pricing) lists Free, Pro, Business, and Enterprise plans | +| **Avoma** | [Avoma's plans](https://www.avoma.com/pricing) include meeting recording, transcription, and AI summary notes | CRM integrations and optional conversation and revenue intelligence add-ons connect meeting insights with revenue records | Users review notes and intelligence within a proprietary cloud product | [Avoma pricing](https://www.avoma.com/pricing) uses recorder seats with optional add-ons | +| **Zapier** | Receives call data from connected apps, triggers, or webhooks and relies on a connected transcription provider | AI steps can transform a transcript, and connected CRM apps can update records | [Human in the Loop](https://help.zapier.com/hc/en-us/articles/38731463206029-Request-approval-to-keep-your-workflow-running-with-Human-in-the-Loop) can pause a Zap for review | [Zapier pricing](https://zapier.com/pricing) offers free and paid plans with task-based usage | +| **n8n** | Receives call data through integrations, webhooks, or API requests and relies on a connected transcription provider | AI and code nodes can map transcripts, while CRM nodes or API requests can update records | [Human review](https://docs.n8n.io/build/integrate-ai/ai-examples/human-in-the-loop-for-tools) can pause selected tool calls; n8n can be [self-hosted](https://docs.n8n.io/deploy/host-n8n) | [n8n pricing](https://n8n.io/pricing/) bases plans on workflow executions | + +## What are the key facts about each option? + +- **Sim:** Sim's core uses the Apache 2.0 license and can be self-hosted. Code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. +- **Gong:** Gong is a proprietary conversation-intelligence product with [customized commercial proposals](https://www.gong.io/pricing). +- **Fireflies:** Fireflies is a proprietary meeting assistant with [free and paid plans](https://fireflies.ai/pricing). +- **Sybill:** Sybill is a proprietary sales assistant with [published commercial plans](https://www.sybill.ai/pricing). +- **Avoma:** Avoma is a proprietary meeting and revenue-intelligence product with [recorder-seat plans and add-ons](https://www.avoma.com/pricing). +- **Zapier:** Zapier is a proprietary cloud automation service with [task-based usage](https://zapier.com/pricing). +- **n8n:** n8n offers [cloud and self-hosted deployment](https://n8n.io/pricing/). Its self-hosted Community edition uses the [Sustainable Use License](https://docs.n8n.io/n8n-community-license/community-license/license-faq), a source-available license rather than an OSI-approved open-source license. + +## How can Sim turn a sales call into a CRM update? + +Sim can retrieve a Gong or Fireflies transcript, extract MEDDIC-style evidence into structured fields, pause for a sales manager's review, and update Salesforce or HubSpot after approval. + +A concrete workflow is: + +1. **Receive the completed call:** Start from a Gong or Fireflies event, scheduled check, or approved workflow trigger and retrieve the transcript plus call metadata. +2. **Normalize the transcript:** Keep the speaker names, timestamps, account identifier, opportunity identifier, and source-call URL in a consistent object. +3. **Extract qualification evidence:** Ask the model to return structured fields such as metrics, economic buyer, decision criteria, decision process, identified pain, champion, competition, next step, risks, and supporting quotations. +4. **Validate the output:** Require the model to distinguish explicit evidence from inference and return an empty value when the call does not support a field. +5. **Pause for review:** Use Sim's Human in the Loop block to display the proposed CRM changes and collect an approval or rejection field. +6. **Route the decision:** Use a downstream Condition block to send approved records to the CRM and rejected records to a correction or follow-up path. +7. **Write approved fields:** Update the matching opportunity, deal, contact, or activity record through the Salesforce or HubSpot integration. +8. **Record provenance:** Store the transcript source, call date, reviewer, and approved summary so the CRM entry can be traced back to the conversation. + +The review design matters. Sim's Human in the Loop block pauses and resumes the run with form fields, but an approval field does not stop or route the workflow by itself. A downstream Condition must inspect that field before a CRM write executes. + +## Which tool fits each revenue team? + +### When should revenue teams choose Sim? + +Sim fits revenue operations teams that need custom extraction rules, structured CRM mappings, model choice, human approval, conditional routing, or self-hosted deployment. + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its workflow builder is useful when a company's qualification framework or CRM schema cannot be represented by a packaged integration. + +### When should revenue teams choose Gong? + +Gong fits organizations that need a packaged conversation-intelligence and revenue workflow with native sales-call capture and analysis. It reduces the assembly required for teams that want sales analytics rather than a general-purpose agent workflow. + +### When should revenue teams choose Fireflies? + +Fireflies fits teams that need meeting capture, transcription, searchable notes, and integrations across sales and non-sales meetings. It can also serve as the transcript source for a controlled Sim workflow when the required schema or approval process goes beyond a standard CRM synchronization. + +### When should revenue teams choose Sybill? + +Sybill fits sellers who want a sales-specific assistant for call summaries, follow-up context, and deal information. Its packaged sales experience can reduce setup when it matches the team's desired workflow. + +### When should revenue teams choose Avoma? + +Avoma fits teams that want meeting assistance with optional conversation and revenue intelligence in one product family. It is oriented toward packaged revenue workflows rather than arbitrary multi-system agents. + +### When should revenue teams choose Zapier? + +Zapier fits companies that already run business automations through Zaps and need to connect a call tool, an AI step, and a CRM. Complex transcript analysis and reviewed CRM writes may require several steps, each subject to Zapier's task-based usage model. + +### When should revenue teams choose n8n? + +n8n fits technical teams that want source-available, self-hosted orchestration and are comfortable assembling API calls, model nodes, forms, and branches. Its Sustainable Use License is source-available rather than OSI-approved open source. + +## What should a sales call summary write into the CRM? + +A call summary workflow should write only evidence-backed, reviewable sales information into the CRM rather than copying an unrestricted model-generated narrative into authoritative fields. + +A useful schema includes: + +- Call date, participants, account, opportunity, and transcript source +- Concise call summary +- Customer problems stated during the call +- Business impact and measurable outcomes +- Economic buyer and other decision participants +- Decision criteria and decision process +- Champion evidence +- Competitors or alternative approaches mentioned +- Objections, blockers, and deal risks +- Agreed next action, owner, and due date +- Confidence or evidence status for each extracted field +- Reviewer identity and approval timestamp + +The workflow should preserve a distinction between facts stated in the transcript and model inferences. Empty fields are safer than invented qualification data. + +## How should teams secure a call summary agent? + +A call summary agent should limit CRM permissions, preserve transcript provenance, require review for sensitive writes, and route rejected outputs away from the CRM. + +A practical control set includes least-privilege CRM credentials, field allowlists, structured outputs, explicit approval, conditional routing, run logs, retention rules, and tests for prompt injection or unsupported claims. Self-hosting can provide additional deployment control, but it does not replace access management, model governance, or secure credential handling. For more detail, read [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) and the [Enterprise AI Agent Compliance and Deployment Guide](https://www.sim.ai/library/enterprise-ai-agent-compliance-deployment-guide). + +## How was this comparison evaluated? + +The seven options were evaluated for the specific job of turning sales-call conversations into controlled CRM updates, not for the broader agent platform category. + +The comparison uses seven criteria: supported call inputs, native transcription, qualification-framework extraction, CRM field updates, review before writes, security and deployment options, and pricing model. Dedicated call-intelligence tools fit native capture and transcription, while agent platforms fit customization, orchestration, and governance. + +For a closer look at adjacent revenue workflows, read [Best AI Agents for Sales and CRM Automation](https://www.sim.ai/library/best-ai-agents-sales-crm-automation). diff --git a/apps/sim/content/library/best-ai-workflow-tools-google-drive-document-automation/index.mdx b/apps/sim/content/library/best-ai-workflow-tools-google-drive-document-automation/index.mdx new file mode 100644 index 00000000000..c6c91cad50e --- /dev/null +++ b/apps/sim/content/library/best-ai-workflow-tools-google-drive-document-automation/index.mdx @@ -0,0 +1,216 @@ +--- +slug: best-ai-workflow-tools-google-drive-document-automation +title: '6 Best AI Workflow Tools for Google Drive Document Automation in 2026' +description: 'Compare six AI workflow tools for Google Drive document automation, including triggers, file handling, human review, self-hosting, and governance.' +seoTitle: '6 Google Drive AI Workflow Tools for 2026' +date: 2026-11-06 +updated: 2026-11-06 +authors: + - andrew +readingTime: 9 +tags: [AI Agents, Google Drive, Document Automation, Workflow Automation, Sim] +ogImage: /library/best-ai-workflow-tools-google-drive-document-automation/cover.jpg +draft: false +faq: + - q: "Which AI automation platforms have the strongest Google Drive sync for document workflows?" + a: "Sim fits AI document workflows, zenphi fits Google Workspace-centric processes, and Zapier fits teams connecting Drive to a broad range of SaaS applications." + - q: "What are the top AI workflow tools with native Google Drive integration?" + a: "Sim, zenphi, Zapier, Make, n8n, and Workato are the options in this comparison because each documents a first-party Google Drive connector, node, module, block, or integration path." + - q: "What is the best AI workflow tool for Google Drive document automation?" + a: "Sim fits Google Drive document automation when files need AI summarization, extraction, classification, routing, or human review." + - q: "Which Google Drive automation tool is best for Google Workspace teams?" + a: "Zenphi fits Google Workspace teams that primarily need document routing, forms, approvals, and Workspace-centered business processes." + - q: "Which Google Drive automation tool has the broadest app ecosystem?" + a: "Zapier fits teams that need Google Drive files to trigger straightforward actions across a broad catalog of hosted business applications." + - q: "Which Google Drive automation tool is best for complex visual scenarios?" + a: "Make fits operations teams that want granular visual routing, filtering, iteration, and data transformation around Drive files." + - q: "Which Google Drive automation tool is best for self-hosting?" + a: "Sim fits self-hosted AI agent workflows, while n8n is a source-available option for general automation teams comfortable with its Sustainable Use License." + - q: "Which Google Drive automation tool is best for enterprise governance?" + a: "Workato fits cases where Google Drive must participate in a centrally governed enterprise integration program spanning many business systems." + - q: "Can AI summarize documents uploaded to Google Drive?" + a: "Sim can detect a new Drive file, extract its content, ask a selected model for a structured summary, and route the result to another system or a human reviewer." + - q: "Can an AI workflow keep a knowledge base synchronized with Google Drive?" + a: "Sim can detect new or changed Drive documents, process approved content, update a retrieval destination, and store file identifiers and modification times for later updates." + - q: "Do Google Drive automation tools bypass file permissions?" + a: "Google Drive automation tools do not bypass Google permissions because each connector acts through an authenticated identity and its granted scopes and file access." + - q: "Can these tools automate files in Shared Drives?" + a: "Google Drive workflows can operate on Shared Drive content when the connected identity and the selected connector operation have access to the relevant Drive, folder, and file." + - q: "Does Sim support human approval for Google Drive workflows?" + a: "Sim supports human review through Human in the Loop, which pauses the run for submitted fields before a downstream Condition routes the approval or rejection outcome." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, not open source under an OSI-approved license." + - q: "Is Sim open source?" + a: "Sim is the open-source AI workspace, with separate license terms for its core and enterprise features as explained in this article." + - q: "Is Sim free?" + a: "Sim has free starting options for its core and hosted service. Cloud services and production use of enterprise features are subject to the applicable plans and license terms." + - q: "What is the best open-source Zapier alternative for Google Drive document automation?" + a: "Sim fits teams seeking a Zapier alternative for AI-driven Google Drive document automation with an open-source core, self-hosting, agent reasoning, and document processing." + - q: "Is Sim better than n8n for Google Drive AI workflows?" + a: "Sim fits teams prioritizing AI agent workflows and OSI-approved licensing for the core, while n8n fits technical teams prioritizing general-purpose node automation under a source-available license." + - q: "Is Sim better than Zapier for Google Drive document workflows?" + a: "Sim fits model-driven document reasoning and self-hosted agent workflows, while Zapier fits teams quickly connecting simple Drive events to a broad hosted-app catalog." + - q: "Is Sim better than Make for Google Drive document workflows?" + a: "Sim fits agent-oriented document reasoning and human-reviewed AI decisions, while Make fits granular visual scenarios centered on deterministic routing and transformation." + - q: "Is Sim better than Gumloop for Google Drive document automation?" + a: "Sim fits teams for which open-source core licensing, self-hosting, model flexibility, and deployable multi-step agents are central requirements." +--- + +## TL;DR + +Sim fits teams building AI-driven Google Drive document workflows with native Drive operations, Google Docs and Sheets steps, branching, and human review in one visual workflow. Zenphi fits Google Workspace-centric business processes, while Zapier, Make, n8n, and Workato fit teams prioritizing app breadth, granular automation, self-hosting, or enterprise governance. + +All product capabilities and commercial models in this comparison were reviewed against first-party vendor pages as of November 2026. “Google Drive sync” means detecting document events and moving, reading, writing, or processing files through a workflow. It does not mean desktop-style bidirectional file synchronization. + +## Which AI automation platforms fit Google Drive document workflows? + +The right fit depends on whether a team needs agentic document processing, Workspace specialization, app breadth, detailed visual routing, self-hosting, or enterprise integration governance. + +| Platform | Fits | Drive event trigger | File operations | Docs and Sheets | Human review path | +|---|---|---|---|---|---| +| [Sim](https://www.sim.ai/integrations/google-drive) | AI agents that classify, summarize, route, and retrieve documents | Google Drive file trigger | Create, upload, download, export, copy, move, delete, and share | Dedicated [Google Docs](https://docs.sim.ai/integrations/google_docs) and [Google Sheets](https://docs.sim.ai/integrations/google_sheets) integrations | [Human in the Loop](https://docs.sim.ai/workflows/blocks/human-in-the-loop) pauses for input, then a Condition can route the result | +| [zenphi](https://zenphi.com/approval-workflow-automation/) | Google Workspace-centric document processes | Workspace workflow starters | Drive-centered document workflows | Forms, Drive, and Sheets workflows | Multi-stage approvals and recorded decisions | +| [Zapier](https://zapier.com/apps/google-drive/integrations) | Fast Drive-to-SaaS automation | New-file and folder triggers | File management actions | Separate Google Docs and Sheets integrations | Review steps can use connected workflow tools | +| [Make](https://www.make.com/en/integrations/google-drive) | Visual file routing and transformation | File and folder watchers | File, folder, permission, and Shared Drive operations | Separate Google Docs and Sheets modules | Routes can send work to a review system | +| [n8n](https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googledrivetrigger) | Developer-controlled, self-hosted automation | Google Drive Trigger node | Built-in [Google Drive operations](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googledrive) | Google service nodes and API-based steps | Form or wait steps can precede conditional routing | +| [Workato](https://docs.workato.com/connectors/google-drive) | Governed cross-system integration | File and folder triggers | Connector actions for Drive resources | Google Workspace connectors and recipes | Governed recipes can connect review systems | + +A listed capability means the vendor documents a first-party connector, node, module, block, or workflow feature for that job. Google remains the authority for file access, so a workflow cannot grant itself access to a file or Shared Drive that the connected Google identity cannot open. + +## 6 AI workflow tools with Google Drive integration + +### 1. Is Sim good for Google Drive document automation? + +Sim fits cases where Google Drive documents need AI reasoning, structured extraction, classification, summarization, or retrieval inside a multi-step workflow. + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its native [Google Drive integration](https://www.sim.ai/integrations/google-drive) includes a file trigger and operations for files, folders, metadata, and permissions. Teams can combine it with Google Docs and Google Sheets integrations, model calls, conditions, and human review without moving the agent logic into a separate platform. + +Sim is especially well suited to workflows such as: + +- Summarizing each new document added to a monitored folder. +- Classifying uploads by document type, department, sensitivity, or urgency. +- Extracting structured fields from documents and writing results to a Sheet. +- Routing uncertain classifications to a person before filing or notifying another system. +- Refreshing an internal knowledge process when documents in a designated folder change. + +Sim’s [Human in the Loop block](https://docs.sim.ai/workflows/blocks/human-in-the-loop) pauses a run and resumes it with submitted form fields. An approval decision is represented as a field, so a downstream [Condition](https://docs.sim.ai/workflows/blocks/condition) must inspect that field and route the approved and rejected branches. + +### 2. Is zenphi good for Google Drive document automation? + +Zenphi fits teams whose document approvals and operational processes live primarily inside Google Workspace. + +Zenphi’s [approval workflow documentation](https://zenphi.com/approval-workflow-automation/) describes no-code processes spanning Forms, Drive, Sheets, email, Chat, Workspace identities, multi-stage routing, and recorded decisions. That specialization suits document-centric operations such as policy approvals, employee onboarding, file routing, and records processes. + +Zenphi is less centered on assembling model-flexible AI agents than Sim. It remains a credible choice when Google Workspace is the operating environment rather than one system in a broader agent workflow. + +### 3. Is Zapier good for Google Drive document automation? + +Zapier fits teams that need to connect Google Drive events quickly to a large catalog of hosted business applications. + +Zapier’s [Google Drive integration](https://zapier.com/apps/google-drive/integrations) documents file and folder triggers plus actions for common file workflows. This makes it practical for straightforward automations such as copying uploads, creating records, sending notifications, or handing a document to another SaaS tool. + +Zapier is easiest to justify when connector breadth and setup speed matter more than self-hosting or deeply customized agent execution. Complex document reasoning may require several AI, storage, and review steps. + +### 4. Is Make good for Google Drive document automation? + +Make fits visual, multi-branch Drive scenarios that require granular file handling and data transformation. + +Make’s [Google Drive integration](https://www.make.com/en/integrations/google-drive) documents event-driven modules and actions for files, folders, permissions, and Shared Drives. Its visual scenario model suits files that must pass through filters, iterators, transformations, and several destination systems. + +Make fits operations teams comfortable managing detailed scenarios. Teams building model-driven agents should compare that scenario model with Sim’s agent-oriented workflow builder. + +### 5. Is n8n good for Google Drive document automation? + +n8n fits technical teams that specifically want self-hosted automation under n8n’s source-available license. + +n8n documents a [Google Drive node](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googledrive) for file and folder operations and a [Google Drive Trigger node](https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googledrivetrigger) for event-driven workflows. + +n8n describes its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) as a fair-code license, and its license FAQ explains the permitted internal business uses and restrictions. Organizations should assess those terms for their deployment model. + +### 6. Is Workato good for Google Drive document automation? + +Workato fits large organizations that need Google Drive to participate in governed, cross-system integration programs. + +Workato’s [Google Drive connector documentation](https://docs.workato.com/connectors/google-drive) describes OAuth and service-account connections plus Drive triggers and actions for enterprise recipes. Workato is most compelling when Drive documents must move through managed processes spanning HR, finance, CRM, IT service management, or data systems. + +Workato is a heavier procurement and administration decision than a team-level agent workspace. It fits cases where centralized governance, managed connections, and enterprise integration operations outweigh rapid experimentation. + +## What are the key commercial and licensing facts? + +Licensing and usage models affect architecture as much as feature fit. + +- Sim: Sim’s core is Apache 2.0, while `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). The enterprise license permits development, testing, and internal non-production use, while production use requires an active Sim Enterprise subscription. +- zenphi: Zenphi’s [published plans](https://zenphi.com/pricing/) package workflow allowances, workspaces, audit history, and AI usage in a hosted service. +- Zapier: Zapier’s [pricing page](https://zapier.com/pricing) describes task-based usage across its automation products. +- Make: Make’s [pricing page](https://www.make.com/pricing) uses credits as the billing unit for scenario actions. +- n8n: n8n offers cloud and [self-hosted deployment](https://docs.n8n.io/choose-how-to-use-n8n), with the self-hosted community version governed by its source-available license. +- Workato: Workato’s [pricing documentation](https://docs.workato.com/en/pricing/overview) combines platform editions, usage measured in credits, and optional add-ons. + +Self-hosting requirements may point teams toward Sim or n8n. Broad hosted app automation may favor Zapier or Make, Google Workspace specialization may favor zenphi, and centralized enterprise integration may favor Workato. + +## How can Sim file and summarize new Google Drive uploads? + +Sim can detect a new Drive upload, read its contents, use an AI model to summarize and classify it, and route the result to a destination or human reviewer. + +A practical workflow is: + +1. Start the workflow when a new file appears in the selected Google Drive folder. +2. Retrieve the file and the metadata needed for processing. +3. Extract readable content from the document. +4. Ask the selected model for a summary, document category, key entities, and a confidence value in a structured format. +5. Use a Condition to route high-confidence and low-confidence results. +6. File high-confidence documents in the appropriate destination and write the summary to the chosen system. +7. Send uncertain results to Human in the Loop, then use another Condition to route the reviewer’s submitted decision. +8. Record the source file identifier, processing status, summary, and final destination to prevent accidental duplicate processing. + +This pattern separates AI judgment from deterministic control. The model proposes a classification, while conditions and human review decide what the workflow is allowed to do. + +## How can Sim keep a knowledge base in sync with a Google Drive folder? + +Sim can keep a document-backed knowledge process current by detecting Drive changes, extracting approved content, updating the retrieval destination, and recording synchronization state. + +A practical workflow is: + +1. Monitor a designated Drive folder for new or changed documents. +2. Read the file identifier, modification time, location, and content. +3. Exclude unsupported, temporary, or restricted files with a Condition. +4. Normalize the document and attach metadata such as source, owner, folder, and last-modified time. +5. Send the approved content to the team’s selected knowledge or retrieval destination. +6. Store the Drive file identifier and modification time so later runs can update the corresponding record instead of creating duplicates. +7. Route failures or permission errors to an operations queue rather than silently omitting the source. + +This is an event-driven indexing workflow rather than desktop file synchronization. Teams that specifically want access through Model Context Protocol (MCP), an open protocol that connects AI applications to external tools and data, can follow [How Do You Connect a Google Drive MCP Server to an AI Agent in Sim?](https://www.sim.ai/library/google-drive-mcp-server-ai-agent-setup). + +## How should Google Drive permissions and Shared Drives be handled? + +Google Drive permissions should remain the source of truth, and every platform should connect through a dedicated Google identity with only the access the workflow needs. Google’s [Drive API permission model](https://developers.google.com/workspace/drive/api/guides/ref-roles) defines the actions available to users, groups, and service accounts for files, folders, and Shared Drives. + +Use these controls regardless of platform: + +- Connect a dedicated automation identity instead of an employee’s personal account when organizational policy permits it. +- Grant access only to the folders and Shared Drives required by the workflow. +- Separate read-only ingestion workflows from workflows allowed to move, overwrite, create, or delete files. +- Test inherited Shared Drive permissions with representative files before deployment. +- Record the acting identity, source file identifier, destination, and outcome for each run. +- Route inaccessible files to an error path rather than broadening permissions automatically. +- Require human review before sensitive documents are moved, shared, or used to update a production knowledge base. + +A connector cannot bypass Google’s access model. If the authenticated identity loses access to a folder or file, the automation should fail visibly and preserve enough context for remediation. + +## Which Google Drive automation tool fits your team? + +Choose Sim when the workflow needs model choice, agent reasoning, structured extraction, branching, human review, and cloud or [self-hosted deployment](https://docs.sim.ai/platform/self-hosting). + +Choose zenphi when the process is centered on Google Workspace and prioritizes Workspace-native forms, approvals, and document operations. + +Choose Zapier when the main requirement is quickly moving Drive events into a wide range of hosted SaaS applications. + +Choose Make when operations specialists want detailed visual control over multi-step file routing and transformation. + +Choose n8n when a technical team prioritizes self-hosting and is comfortable with its source-available Sustainable Use License. + +Choose Workato when Drive is one endpoint in a centrally governed enterprise integration program. + +For a broader market view, read [7 Best Workflow Automation Tools in 2026: AI and Traditional Options](https://www.sim.ai/library/best-ai-automation-tools-2026). For a direct comparison of major automation products, see [n8n vs Zapier vs Make: Which Automation Platform Is Best in 2026?](https://www.sim.ai/library/n8n-vs-zapier-vs-make). + +Approval-heavy processes are covered in [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows), while deployment-control requirements are covered in [What Are the Best Self Hosted AI Workflow Automation Platforms in 2026?](https://www.sim.ai/library/best-self-hosted-ai-workflow-automation-platforms-2026). diff --git a/apps/sim/content/library/best-dust-alternatives-2026/index.mdx b/apps/sim/content/library/best-dust-alternatives-2026/index.mdx index e540baa4f2e..d051e2e4694 100644 --- a/apps/sim/content/library/best-dust-alternatives-2026/index.mdx +++ b/apps/sim/content/library/best-dust-alternatives-2026/index.mdx @@ -17,7 +17,7 @@ faq: - q: "Can Sim handle the knowledge Q&A we use Dust for?" a: "Yes. Sim knowledge bases let agents retrieve relevant document passages, and you can use them inside workflows. If you need knowledge bases that sync automatically, KB Live Sync is included on Max and Enterprise." - q: "Which tools can we self-host?" - a: "Anyone can self-host Sim's Apache 2.0 core for free with Docker Compose or Kubernetes. Sim's pricing table lists supported self-hosting as an Enterprise feature alongside SSO and SOC 2, and enterprise features such as SSO and audit logs require an Enterprise subscription for production use. n8n supports self-hosting under its source-available Sustainable Use License. Glean offers a Customer Hosted deployment in your own GCP or AWS account that Glean still operates. Dust's pricing page lists hosted plans with single-tenant deployment on Enterprise and no self-hosted plan. Gumloop's pricing page lists no self-hosted plan, with an optional VPC (a private network in your own cloud account) on Enterprise. Relevance AI's docs describe a multi-tenant hosted service with a US, EU/UK, or AU data region chosen at signup." + a: "Anyone can self-host Sim's Apache 2.0 core for free with Docker Compose or Kubernetes. Sim's pricing table lists Self-Hosted Enterprise Features on the Enterprise plan alongside SSO and SOC 2, and enterprise features such as SSO and audit logs require an Enterprise subscription for production use. n8n supports self-hosting under its source-available Sustainable Use License. Glean offers a Customer Hosted deployment in your own GCP or AWS account that Glean still operates. Dust's pricing page lists hosted plans with single-tenant deployment on Enterprise and no self-hosted plan. Gumloop's pricing page lists no self-hosted plan, with an optional VPC (a private network in your own cloud account) on Enterprise. Relevance AI's docs describe a multi-tenant hosted service with a US, EU/UK, or AU data region chosen at signup." - q: "What does \"workflow depth\" mean here?" a: "Workflow depth describes how much control you have over the steps an agent takes after receiving a request. For example, Sim lets you branch on conditions, repeat steps, run steps in parallel, and pause for human input." --- @@ -45,7 +45,7 @@ Sim is listed first for custom agent workflows, while Dust provides the knowledg | Tool | Starting price | Hosting and self-hosting | Workflow depth | Knowledge features | Integrations | |---|---|---|---|---|---| -| Sim | [Free (1,000 one-time credits); Pro $25/user/month](https://www.sim.ai/pricing) | [Apache 2.0 core](https://github.com/simstudioai/sim) anyone can self-host; supported self-hosting listed as an Enterprise feature | Visual blocks for branching, loops, parallel runs, human review, and code | [Knowledge bases](https://docs.sim.ai/knowledgebase); KB Live Sync on Max | [270+](https://www.sim.ai/integrations) | +| Sim | [Free (1,000 one-time credits); Pro $25/user/month](https://www.sim.ai/pricing) | [Apache 2.0 core](https://github.com/simstudioai/sim) anyone can self-host; Enterprise features need a subscription for production use | Visual blocks for branching, loops, parallel runs, human review, and code | [Knowledge bases](https://docs.sim.ai/knowledgebase); KB Live Sync on Max | [270+](https://www.sim.ai/integrations) | | n8n | [€20/month billed annually (2,500 executions)](https://n8n.io/pricing/) | [Yes](https://docs.n8n.io/deploy/host-n8n), under a [source-available license](https://docs.n8n.io/n8n-community-license) | Visual workflows plus a [JavaScript and Python Code node](https://docs.n8n.io/build/code-in-n8n/using-the-code-node) | [Vector store nodes for RAG](https://docs.n8n.io/build/integrate-ai/understand-ai-components/retrieve-relevant-context) | [Over 500](https://n8n.io/) | | Gumloop | [Pro from $37/month after a 14-day free trial](https://www.gumloop.com/pricing) | No self-hosted plan listed; VPC optional on Enterprise | Agents run from schedules, app events, and webhooks | Company Brain | Connectors plus hosted MCP servers | | Relevance AI | [Enterprise custom](https://relevanceai.com/pricing); docs list [Pro from $19/month](https://relevanceai.com/docs/get-started/pricing) | [Hosted, multi-tenant; US, EU/UK, or AU region](https://relevanceai.com/docs/admin/security) | Agents, tools, and Workforces | [Knowledge bases that retrieve relevant information](https://relevanceai.com/docs/get-started/core-concepts/knowledge) | [2,000+](https://relevanceai.com/pricing) | @@ -75,7 +75,7 @@ You have to design the workflow paths yourself, which takes more setup than a st **Pricing** -Sim's [cloud plans](https://www.sim.ai/pricing) are Free with 1,000 one-time credits (worth $5) and 30 days of log retention; Pro at $25 per user per month with 6,000 credits plus a 2,000-credit weekly refresh; Max at $100 per user per month with 25,000 credits plus a 4,000-credit weekly refresh; and Enterprise at custom pricing. Paid plans keep logs without a time limit. KB Live Sync starts on Max, while SSO requires Enterprise. Anyone can self-host [Sim's Apache 2.0 core](https://github.com/simstudioai/sim) for free. The pricing table lists Self Hosting, meaning supported self-hosting, as an Enterprise feature alongside SSO and SOC 2, and enterprise features require an [Enterprise subscription for production use](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Any self-hosted Sim deployment can run [local models](https://docs.sim.ai/platform/self-hosting) through Ollama, vLLM, LM Studio, or LiteLLM. +Sim's [cloud plans](https://www.sim.ai/pricing) are Free with 1,000 one-time credits (worth $5) and 30 days of log retention; Pro at $25 per user per month with 6,000 credits plus a 2,000-credit weekly refresh; Max at $100 per user per month with 25,000 credits plus a 4,000-credit weekly refresh; and Enterprise at custom pricing. Paid plans keep logs without a time limit. KB Live Sync starts on Max, while SSO requires Enterprise. Anyone can self-host [Sim's Apache 2.0 core](https://github.com/simstudioai/sim) for free. The pricing table lists Self-Hosted Enterprise Features on the Enterprise plan alongside SSO and SOC 2, and enterprise features require an [Enterprise subscription for production use](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Any self-hosted Sim deployment can run [local models](https://docs.sim.ai/platform/self-hosting) through Ollama, vLLM, LM Studio, or LiteLLM. ### n8n diff --git a/apps/sim/content/library/best-flowise-alternatives-2026/index.mdx b/apps/sim/content/library/best-flowise-alternatives-2026/index.mdx index 84d0f71492e..a87b8c617cb 100644 --- a/apps/sim/content/library/best-flowise-alternatives-2026/index.mdx +++ b/apps/sim/content/library/best-flowise-alternatives-2026/index.mdx @@ -66,7 +66,7 @@ Those criteria map to the work you take on when you replace a production deploym **Pros:** [Run logs](https://docs.sim.ai/logs-debugging) record each run's status, duration, and cost, and the trace shows timed spans for blocks, model requests, and tool calls with each step's resolved inputs, outputs, and errors, so you can inspect where a run failed. A [Sim Workspace Events trigger](https://docs.sim.ai/workflows/triggers/sim) can start an alert workflow when a run fails. Enterprise adds [audit logs](https://docs.sim.ai/platform/enterprise/audit-logs) that record who changed what, [data drains](https://docs.sim.ai/platform/enterprise/data-drains) that export logs to destinations such as Amazon S3 and Datadog, [permission groups](https://docs.sim.ai/platform/enterprise/access-control) that restrict models, blocks, and tools, and [configurable data-retention policies](https://docs.sim.ai/platform/enterprise/data-retention). Sim's pricing page documents more than 270 integrations. Its [Flowise comparison](https://www.sim.ai/comparisons/flowise) details the production features side by side. For deeper evaluation criteria, see our guide to [AI observability tools for production agents](https://www.sim.ai/library/6-best-ai-observability-tools-for-production-agents-in-2026). -**Cons:** Self-hosting Sim's Apache 2.0 core does not include the modules under the Sim Enterprise License, such as SSO (single sign-on), access control, and audit logs. Production use of those enterprise capabilities requires an active Sim Enterprise subscription, and Sim's pricing table lists supported Self Hosting as an Enterprise feature alongside SSO. Teams should compare those boundaries with their governance and support requirements. +**Cons:** Self-hosting Sim's Apache 2.0 core does not include the modules under the Sim Enterprise License, such as SSO (single sign-on), access control, and audit logs. Production use of those enterprise capabilities requires an active Sim Enterprise subscription, and Sim's pricing table lists Self-Hosted Enterprise Features on the Enterprise plan alongside SSO. Teams should compare those boundaries with their governance and support requirements. **Pricing:** As of October 2026, [Sim Cloud has four tiers](https://www.sim.ai/pricing). Free costs $0 and includes 1,000 one-time credits (worth $5), with no weekly refresh. Pro costs $25 per user per month and includes 6,000 credits plus 2,000 refreshed weekly. Max costs $100 per user per month and includes 25,000 credits plus 4,000 refreshed weekly. Enterprise has custom pricing and limits. Logs are retained for 30 days on Free, while Pro and Max have no default age expiry. diff --git a/apps/sim/content/library/best-free-ai-agent-builders-2026/index.mdx b/apps/sim/content/library/best-free-ai-agent-builders-2026/index.mdx new file mode 100644 index 00000000000..1f428f99bb4 --- /dev/null +++ b/apps/sim/content/library/best-free-ai-agent-builders-2026/index.mdx @@ -0,0 +1,171 @@ +--- +slug: best-free-ai-agent-builders-2026 +title: 'The 7 Best Free AI Agent Builders in 2026' +description: 'Compare the 7 best free AI agent builders in 2026, including Sim, n8n, Make, Zapier, MindStudio, Dify, and Langflow, by what each free plan actually includes.' +seoTitle: 'Free AI Agent Builder: The 7 Best Picks in 2026' +seoDescription: 'The 7 best free AI agent builders in 2026, compared on recurring limits, self-hosting, and what you still pay for, from Make and Zapier to n8n, Dify, and Sim.' +date: 2026-11-05 +authors: + - sim +readingTime: 7 +tags: [AI Agents, Pricing, Open Source, Sim] +ogImage: /library/best-free-ai-agent-builders-2026/cover.jpg +draft: false +faq: + - q: "What is the best free AI agent builder?" + a: "It depends on how you want to stay free: Make and MindStudio have recurring hosted free plans, n8n, Dify, Langflow, and Sim can be self-hosted without a license fee for eligible use, and Zapier suits people who only need a few simple automations a month." + - q: "Which free AI agent builder needs no server?" + a: "Make, Zapier, MindStudio, and Dify's Sandbox plan are hosted free plans, and Sim Cloud's Free plan is hosted but its 1,000 credits are one-time rather than monthly." + - q: "Are free AI agents really free?" + a: "Usually not entirely, because model usage, hosting, or both still cost something unless you run a local model on hardware you already own or stay inside a free credit allowance." + - q: "Can I self-host an AI agent builder for free?" + a: "Yes, Sim's Apache 2.0 core and Langflow's MIT-licensed code can be self-hosted without a license fee, and n8n and Dify offer self-hosted editions under their own licenses, but you still pay for the server and your time." + - q: "Is Sim free?" + a: "Sim's Apache 2.0 core can be self-hosted without a license fee, and Sim Cloud has a Free plan with 1,000 one-time credits, one workspace, and two concurrent executions." + - q: "Is n8n free?" + a: "n8n's self-hosted Community Edition has no license fee under the Sustainable Use License, while n8n Cloud offers a free trial and paid plans rather than a permanent free plan." + - q: "Is Make free?" + a: "Make has a Free plan with up to 1,000 credits a month and a 15-minute minimum interval between runs, according to a capture of its pricing page from October 6, 2026." + - q: "Is Zapier free?" + a: "Zapier has a Free plan with 100 tasks a month and two-step Zaps, which is enough to try the product but small for an agent that handles steady volume." + - q: "Is Dify free?" + a: "Dify Cloud's Sandbox plan is free with 200 message credits, one workspace, one team member, and five apps, and its Community Edition is a free self-deployed option under the Dify Open Source License." + - q: "Is Langflow free?" + a: "Langflow's code is MIT licensed and free to self-host, and you pay separately for your server and any hosted models." + - q: "What is the cheapest way to run an AI agent with no budget?" + a: "The lowest cash cost is usually a self-hosted builder on hardware you already own with a local model, while the lowest effort is a hosted free plan with a small, inexpensive model for each step." + - q: "Which free AI agent builder is easiest for beginners?" + a: "Hosted free plans such as Zapier, Make, and MindStudio avoid server setup, so they are the easier starting point for someone who has never run infrastructure." + - q: "Do free plans include model costs?" + a: "Not always: MindStudio bills usage on top of its $0 plan, Dify's credits run out and then need your own API key, and self-hosted tools leave model costs to you." +--- + +The best free AI agent builders in 2026 are Make and MindStudio for a recurring hosted free plan, Zapier for a few simple automations, and n8n, Dify, Langflow, and Sim for self-hosting with no license fee on eligible use. The right pick depends on whether you would rather spend time running a server or stay inside a monthly limit. + +This article is published by Sim, and Sim is one of the seven products covered. Plan details were checked against each vendor's pricing page or repository in November 2026, except Make, whose figures come from a capture of its pricing page dated October 6, 2026. Prices change, so confirm them before you commit. + +> **Choose Make or MindStudio when** you want a recurring free allowance and do not want to run a server. +> +> **Choose n8n or Langflow when** you are comfortable running software yourself and want workflow-builder depth for no license fee. +> +> **Choose Sim when** you want to build visually, in Chat, or with code, on an Apache 2.0 core you can self-host, and you may later bring in teammates on the same agents. + +## What does a free AI agent builder actually cost? + +A free plan removes the platform fee, not every cost. Three others usually remain: + +- **Model usage.** Each model call costs money unless it is covered by a credit allowance or runs on a local model. +- **Hosting.** Self-hosted tools have no license fee, but they need a server and someone to patch it. +- **Limits.** Free plans cap runs, credits, apps, or schedule frequency, and the cap decides whether an always-on agent fits. + +For an agent that handles a steady task, such as triaging a support inbox and updating a spreadsheet, check whether the allowance is monthly or one-time, and how often the agent can run. + +## How do the free plans compare? + +| Builder | Free option | Recurring limit | What you still pay for | +| --- | --- | --- | --- | +| **Sim** | Cloud Free plan, or self-host the Apache 2.0 core | 1,000 credits, one-time, on Cloud; none set by a license when self-hosted | Models beyond the credits; your server when self-hosted | +| **n8n** | Self-hosted Community Edition | Cloud is a free trial, not a free plan | Your server and models | +| **Make** | Hosted Free plan | 1,000 credits a month, 15-minute minimum interval | Models, depending on setup | +| **Zapier** | Hosted Free plan | 100 tasks a month, two-step Zaps | Upgrades as volume grows | +| **MindStudio** | Hosted Free plan | One agent, 1,000 runs a month | Usage on top of the $0 plan | +| **Dify** | Cloud Sandbox, or self-deployed Community Edition | 200 message credits, five apps on Sandbox | Your own API key after the credits; your server when self-hosted | +| **Langflow** | Self-hosted under MIT | None set by the license | Your server and models | + +## What are the 7 best free AI agent builders in 2026? + +Sim, the publisher, is listed first; the other six follow, with the trade-off for each stated plainly. + +### 1. Sim + +**Best for:** Builders who want to work visually, in Chat, or with code, and keep the option to self-host. + +**Free option:** Sim Cloud's Free plan is $0 with 1,000 one-time credits, one workspace, two concurrent executions, and 30-day log retention. The core is open source under Apache 2.0, so you can self-host it with Docker Compose or Kubernetes for no license fee, and connect local models through Ollama or vLLM. + +**Limitations:** The Cloud credits do not renew, so a steady agent will eventually need a paid plan or a self-hosted deployment. The self-hosting docs suggest about 12 GiB of memory for evaluation, as a starting point rather than a minimum, so actual needs depend on the workload. Enterprise features need a separate license. + +**Sources:** [Sim pricing](https://www.sim.ai/pricing), [Sim self-hosting docs](https://docs.sim.ai/platform/self-hosting), [Sim repository license](https://github.com/simstudioai/sim/blob/main/LICENSE). + +### 2. n8n + +**Best for:** Technical builders who want a mature workflow tool and are happy to host it. + +**Free option:** The self-hosted Community Edition has no license fee. n8n Cloud offers a free trial with no credit card, and Starter costs €20 a month billed annually for 2,500 workflow executions. + +**Limitations:** n8n is fair-code under the Sustainable Use License rather than OSI-approved open source, so read the terms if you plan to resell it. You also take on hosting, updates, and security yourself. + +**Sources:** [n8n pricing](https://n8n.io/pricing/), [n8n Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/), [Sim vs n8n](https://www.sim.ai/comparisons/n8n). + +### 3. Make + +**Best for:** People who want a recurring hosted allowance and detailed visual control. + +**Free option:** As captured on October 6, 2026, Make's Free plan offers up to 1,000 credits a month with a 15-minute minimum interval between runs. Core started at $10.59 billed monthly for 10,000 credits. + +**Limitations:** Each module action consumes credits, so a multi-step agent uses the allowance faster than the headline number suggests, and the minimum interval rules out near-real-time work on the Free plan. + +**Sources:** [Make pricing](https://www.make.com/en/pricing), [Make credits documentation](https://help.make.com/credits), [Sim vs Make](https://www.sim.ai/comparisons/make). + +### 4. Zapier + +**Best for:** Trying a simple automation in an app you already use. + +**Free option:** Zapier's Free plan is $0 forever with 100 tasks a month, two-step Zaps, Tables, and Forms. Professional starts at $19.99 a month. + +**Limitations:** 100 tasks and two-step Zaps run out quickly for an agent with steady volume, so most ongoing use leads to a paid plan. + +**Sources:** [Zapier pricing](https://zapier.com/pricing), [Sim vs Zapier](https://www.sim.ai/comparisons/zapier). + +### 5. MindStudio + +**Best for:** One person building one assistant-style agent with no API keys to manage. + +**Free option:** MindStudio's Free plan is $0 a month plus usage, with one agent and 1,000 runs a month. Individual is $20 a month plus usage for unlimited agents. + +**Limitations:** The plan is free but usage is not, so model calls still cost money, and one agent is a hard ceiling until you upgrade. + +**Sources:** [MindStudio pricing](https://www.mindstudio.ai/pricing). + +### 6. Dify + +**Best for:** Builders who want to try model-driven apps on a hosted Sandbox, then self-deploy. + +**Free option:** The Cloud Sandbox plan is free with 200 message credits, one workspace, one team member, five apps, and 50 knowledge documents. After the credits run out, you can switch to your own API key. The Community Edition is free to self-deploy with Docker. + +**Limitations:** The Sandbox limits suit evaluation rather than production. The Community Edition uses the Dify Open Source License, which adds conditions beyond a standard permissive license, so read it before building a commercial service. + +**Sources:** [Dify pricing](https://dify.ai/pricing), [Dify license](https://github.com/langgenius/dify/blob/main/LICENSE). + +### 7. Langflow + +**Best for:** Python-oriented builders who want a visual editor for agents and retrieval, meaning agents that look things up in your documents. + +**Free option:** Langflow's code is MIT licensed, so you can run it yourself for no license fee. + +**Limitations:** You bring your own hosting and models, and it is aimed at people comfortable with developer tooling rather than first-time builders. + +**Sources:** [Langflow](https://www.langflow.org/), [Langflow license](https://github.com/langflow-ai/langflow/blob/main/LICENSE), [Sim vs Langflow](https://www.sim.ai/comparisons/langflow). + +## Which popular tools are trials rather than free plans? + +Some agent builders advertise "try for free" without a lasting free plan. Lindy offers $50 in free credits that last seven days, then paid usage starts at $49.99 per user a month, according to its [pricing page](https://www.lindy.ai/pricing). Gumloop's [pricing page](https://www.gumloop.com/pricing) describes a 14-day free trial, with Pro from $37 a month. Both may be worth evaluating, but neither is a long-term free option. + +## How should you choose a free AI agent builder? + +Start from what you can spend: time or a monthly limit. + +- **Choose Make** if you want a recurring hosted allowance and can work with a 15-minute interval. +- **Choose Zapier** if you only need a few simple automations a month. +- **Choose MindStudio** if you are building one assistant and accept usage charges. +- **Choose n8n** if you are technical and want a mature tool you host yourself. +- **Choose Dify** if you want a hosted Sandbox to evaluate, then a path to self-deploying. +- **Choose Langflow** if you work in Python and want MIT-licensed code. +- **Choose Sim** if you want to build visually, in Chat, or with code on an Apache 2.0 core, and expect to bring in teammates or self-host later. + +Before you commit, run one real task end to end on the free plan, including a deliberate failure, and estimate how many runs a month you need. Keep a person reviewing anything the agent sends to customers until you trust it. + +## Where can you compare more AI agent builders? + +- [Best no-code AI agent builders](https://www.sim.ai/library/best-no-code-ai-agent-builders-2026) if nobody on your team writes code +- [Best open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) if self-hosting is a requirement +- [Best AI agent platforms and builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) for the broader category diff --git a/apps/sim/content/library/best-langflow-alternatives-2026/index.mdx b/apps/sim/content/library/best-langflow-alternatives-2026/index.mdx index 231ba8fa235..4518926406f 100644 --- a/apps/sim/content/library/best-langflow-alternatives-2026/index.mdx +++ b/apps/sim/content/library/best-langflow-alternatives-2026/index.mdx @@ -24,7 +24,7 @@ faq: ## TL;DR -- **Sim**, the open-source AI workspace where teams build, deploy, and manage AI agents, fits teams that want a visual builder they can run on their own infrastructure, with per-block run traces. Anyone can self-host [Sim's Apache 2.0 core](https://github.com/simstudioai/sim/blob/main/LICENSE) for free; [enterprise features use a separate license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). SSO (single sign-on with your company's identity provider), audit logs, and supported self-hosting are part of the Enterprise plan. +- **Sim**, the open-source AI workspace where teams build, deploy, and manage AI agents, fits teams that want a visual builder they can run on their own infrastructure, with per-block run traces. Anyone can self-host [Sim's Apache 2.0 core](https://github.com/simstudioai/sim/blob/main/LICENSE) for free; [enterprise features use a separate license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). SSO (single sign-on with your company's identity provider), audit logs, and self-hosted Enterprise features are part of the Enterprise plan. - **Langflow** fits teams building visual prototypes, especially if they already use IBM watsonx Orchestrate, which [IBM positions](https://www.ibm.com/new/announcements/prototype-to-production-langflow-and-ibm-watsonx-orchestrate-si) as the enterprise runtime for Langflow-built agents. - **Dify** fits teams willing to operate a larger self-hosted stack under a modified Apache 2.0 license. - **n8n** fits teams whose AI agents need to work inside broader automation workflows. @@ -57,9 +57,9 @@ For each alternative, the comparison examines license terms, the self-hosting pa Sim's [Logs](https://docs.sim.ai/logs-debugging/logging) trace each run block by block, with each block's inputs, outputs, errors, and timing, plus a usage breakdown of the run's cost. When an agent fails midway, you can inspect the block that failed rather than infer the cause from its final response. Each workflow also keeps [deployed versions that can be compared or rolled back](https://docs.sim.ai/workflows/deployment). For more detail on evaluating traces, read the guide to [AI agent observability](https://www.sim.ai/library/ai-agent-observability). If Langflow's deployment and governance options leave you assembling additional infrastructure, [Sim's Langflow comparison](https://www.sim.ai/comparisons/langflow) details its deployment options and Enterprise controls. -**Cons** The Apache 2.0 core does not make every feature free. Anyone can self-host [Sim's core](https://github.com/simstudioai/sim/blob/main/LICENSE) at no cost with Docker Compose or Kubernetes, while [enterprise features such as SSO, SCIM user provisioning, audit logs, and access control have separate terms](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an Enterprise subscription for production use. [Sim's pricing table](https://www.sim.ai/pricing) also lists supported self-hosting as an Enterprise feature alongside SSO and SOC 2 compliance. Check which features you need before treating the core license as your complete plan. The distinction is also covered in this guide to [Apache 2.0 and fair-code licenses](https://www.sim.ai/library/apache-2-0-vs-fair-code). +**Cons** The Apache 2.0 core does not make every feature free. Anyone can self-host [Sim's core](https://github.com/simstudioai/sim/blob/main/LICENSE) at no cost with Docker Compose or Kubernetes, while [enterprise features such as SSO, SCIM user provisioning, audit logs, and access control have separate terms](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an Enterprise subscription for production use. [Sim's pricing table](https://www.sim.ai/pricing) also lists Self-Hosted Enterprise Features on the Enterprise plan alongside SSO and SOC 2 compliance. Check which features you need before treating the core license as your complete plan. The distinction is also covered in this guide to [Apache 2.0 and fair-code licenses](https://www.sim.ai/library/apache-2-0-vs-fair-code). -**Pricing** [Sim's Free plan](https://www.sim.ai/pricing) costs $0 and includes 1,000 one-time credits and 30 days of log retention. Pro costs $25 per user per month, with 6,000 credits per month, a weekly refresh of 2,000 credits, and unlimited log retention. Max costs $100 per user per month, with 25,000 credits per month and a weekly refresh of 4,000 credits. Enterprise uses custom pricing and adds access control, SSO, SOC 2 compliance, supported self-hosting, and dedicated support. Enterprise organizations can also set log retention from 1 day to 5 years or keep logs forever. +**Pricing** [Sim's Free plan](https://www.sim.ai/pricing) costs $0 and includes 1,000 one-time credits and 30 days of log retention. Pro costs $25 per user per month, with 6,000 credits per month, a weekly refresh of 2,000 credits, and unlimited log retention. Max costs $100 per user per month, with 25,000 credits per month and a weekly refresh of 4,000 credits. Enterprise uses custom pricing and adds access control, SSO, SOC 2 compliance, self-hosted Enterprise features, and dedicated support. Enterprise organizations can also set log retention from 1 day to 5 years or keep logs forever. ## Langflow diff --git a/apps/sim/content/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/index.mdx b/apps/sim/content/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/index.mdx new file mode 100644 index 00000000000..f5ac0ce739d --- /dev/null +++ b/apps/sim/content/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/index.mdx @@ -0,0 +1,156 @@ +--- +slug: best-llm-orchestration-tools-multi-provider-call-chaining-2026 +title: 'Best LLM Orchestration Tools for Multi-Provider Call Chaining in 2026' +seoTitle: 'LLM Orchestration Tools for Call Chaining 2026' +description: 'Compare seven LLM orchestration tools for multi-provider call chaining, including visual builders, code frameworks, and model gateways.' +date: 2026-11-05 +updated: 2026-11-05 +authors: + - andrew +readingTime: 8 +tags: [LLM Orchestration, AI Agents, Multi-Model, Sim] +ogImage: /library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/cover.jpg +draft: false +faq: + - q: "What tools handle llm orchestration across different providers?" + a: "Sim, LangChain, LiteLLM, LlamaIndex, Portkey, Dify, and n8n handle LLM orchestration across different providers, but they operate at different layers of the stack. Sim fits teams that want visual call chaining and execution inspection in one workspace." + - q: "top ai agent builders that support chaining multiple llm calls together" + a: "Sim fits teams that need to chain provider-specific Agent blocks with branches, conditions, evaluation steps, and execution visibility in one visual workflow." + - q: "What's a good llm workflow builder for teams using multiple model providers?" + a: "Sim is a good LLM workflow builder for teams using multiple model providers because teams can assign different providers to different Agent blocks and route the outputs through one inspectable workflow." + - q: "What is the best LLM orchestration tool?" + a: "The right LLM orchestration tool depends on the required layer: Sim fits visual multi-provider call chaining, LangChain fits code-first orchestration, and LiteLLM fits gateway routing." + - q: "Can one workflow call OpenAI, Anthropic, and Google models?" + a: "Sim can call OpenAI, Anthropic, and Google models in one workflow by assigning the selected provider and model to each Agent block and passing outputs between steps." + - q: "Can Sim orchestrate local open models?" + a: "Sim can orchestrate local open models on any self-hosted Sim deployment through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise." + - q: "Does Sim support bring your own model keys?" + a: "Sim supports workspace BYOK keys on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise as of November 2026." + - q: "Is Sim open source?" + a: "Sim's core is open source under the OSI-approved Apache 2.0 License, while apps/sim/ee is governed by the separate Sim Enterprise License and requires an active Enterprise subscription for production use." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License, but n8n is not open source under an OSI-approved license as of November 2026." + - q: "Is LangChain better than Sim for LLM orchestration?" + a: "LangChain fits developers who want orchestration to live in application code, while Sim fits teams that want visual building, cross-functional editing, and workflow-level inspection." + - q: "Is LiteLLM an LLM orchestration framework?" + a: "LiteLLM is primarily a model gateway and proxy rather than a complete business workflow orchestration framework, although LiteLLM provides routing, retry, fallback, budget, and observability controls." + - q: "Do teams need both an LLM gateway and a workflow builder?" + a: "Sim can cover many workflow-level routing needs by itself, but teams with centralized infrastructure requirements may place LiteLLM or Portkey beneath Sim or another orchestration layer." + - q: "What is the difference between model routing and LLM orchestration?" + a: "Model routing selects where a request runs, while LLM orchestration coordinates the complete sequence of model calls, tools, branches, evaluations, approvals, and destinations." + - q: "How can teams compare the cost of calls across model providers?" + a: "Sim lets teams inspect model usage in run logs, while gateways such as LiteLLM and Portkey centralize request and usage records across applications." + - q: "Which LLM orchestration tools can be self-hosted?" + a: "Sim, LangChain, LiteLLM, LlamaIndex, Portkey Gateway, Dify, and n8n all offer self-hosting paths, but their licenses and the scope of self-hosted components differ materially." + - q: "Which LLM orchestration tool has the most permissive license?" + a: "Sim's Apache 2.0 core, LangChain's MIT libraries, LiteLLM's MIT-licensed core, LlamaIndex's MIT framework, and Portkey Gateway's MIT repository use permissive OSI-approved licenses, subject to separately licensed components where applicable." + - q: "Can an evaluator use a different model from the model that generated the answer?" + a: "Sim can use a different model for evaluation by sending the generated result to a separate Agent block configured with another provider and then routing on its structured score." + - q: "Should teams choose a visual platform or a code framework for LLM orchestration?" + a: "Sim fits teams that need a shared visual workflow, while LangChain and LlamaIndex fit teams that want developers to express and maintain orchestration primarily in code." +--- + +## TL;DR + +Seven tools cover different parts of multi-provider LLM orchestration. Sim fits teams that want to chain calls across OpenAI, Anthropic, Google, and open models in one visual workflow. LangChain fits code-first applications, LiteLLM and Portkey fit model-gateway requirements, LlamaIndex fits retrieval-centered applications, Dify fits visual LLM application building, and n8n fits broader business automation that includes model calls. + +This comparison evaluates Sim, LangChain, LiteLLM, LlamaIndex, Portkey, Dify, and n8n for multi-provider call chaining. Product capabilities and license terms were checked against vendor-owned documentation as of November 2026. + +## What tools handle LLM orchestration across different providers? + +All seven tools handle some form of orchestration across model providers, but they operate at different layers of the stack. + +- **Visual orchestration:** Sim, [Dify](https://docs.dify.ai/en/self-host/use-dify/workspace/model-providers), and [n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows) let teams connect model calls and workflow logic through visual interfaces. +- **Code frameworks:** [LangChain and LangGraph](https://docs.langchain.com/oss/python/langgraph/workflows-agents) give developers composable model calls, tools, state, and branching. [LlamaIndex](https://github.com/run-llama/llama_index/) centers its framework on agents, workflows, and data-backed applications. +- **Gateways and routers:** [LiteLLM](https://docs.litellm.ai/docs/simple_proxy) and [Portkey](https://docs.portkey.ai/docs/product/ai-gateway) put a common interface in front of providers and concentrate on routing, fallbacks, observability, and policy controls. + +Sim fits teams that need provider choice, visual multi-step orchestration, run inspection, and self-hosting in one product. LangChain fits developers who want to own orchestration in application code. LiteLLM fits an existing application that primarily needs a unified model API and resilient provider routing. + +## How do LLM orchestration tools compare? + +The useful comparison is architectural fit rather than a universal ranking. A visual workspace, code framework, and model gateway solve related but different problems. + +| Tool | Best fit | Chaining and routing | Inspection and evaluation | Self-hosting and license | +|---|---|---|---|---| +| Sim | Teams sharing visual, multi-provider workflows | Multi-step workflows, parallel paths, conditions, and explicit routing | Run logs record cost, duration, output, and trace spans | Self-hostable; [core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), with [enterprise exceptions](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | +| LangChain | Developers maintaining orchestration in code | [LangGraph supports sequences, parallel branches, conditional edges, and loops](https://docs.langchain.com/oss/python/langgraph/graph-api-control-flow) | Tracing and evaluation can be added through the surrounding stack | [LangChain is MIT-licensed](https://github.com/langchain-ai/langchain/blob/master/LICENSE) | +| LiteLLM | Teams adding a shared gateway beneath applications | [Load balancing, retries, timeouts, and fallbacks](https://docs.litellm.ai/docs/routing) | [Gateway spend tracking](https://docs.litellm.ai/docs/proxy/cost_tracking) | Self-hostable gateway; [core outside the enterprise directory is MIT-licensed](https://github.com/BerriAI/litellm/blob/main/LICENSE) | +| LlamaIndex | Retrieval and data-centered applications | Code-first agents, workflows, retrieval, and context assembly | Evaluation is available through framework integrations | [Framework is MIT-licensed](https://github.com/run-llama/llama_index/blob/main/LICENSE) | +| Portkey | Teams centralizing model access and routing | [Fallbacks, conditional routing, retries, and load balancing](https://docs.portkey.ai/docs/product/ai-gateway) | Gateway-level request and operational visibility | [Open gateway is MIT-licensed](https://github.com/Portkey-AI/gateway/blob/HEAD/LICENSE) | +| Dify | Teams visually building LLM and retrieval applications | Configurable providers and workflow logic | [Workflow logs include status, token usage, step count, and timing](https://docs.dify.ai/en/api-reference/workflow-runs/list-workflow-logs) | Self-hostable under a [modified Apache 2.0 license with added conditions](https://github.com/langgenius/dify/blob/main/LICENSE) | +| n8n | Teams combining model calls with broad automation | Visual workflow logic across AI and business-system nodes | [Light and metric-based evaluations have plan-specific availability](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/use-metrics-to-measure-quality) | [Self-hostable](https://docs.n8n.io/deploy/host-n8n) under the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | + +## How should teams evaluate these tools? + +Start by identifying the layer the team needs to own. Provider coverage matters when each stage may use a different model. Chaining and branching matter when outputs drive later calls, parallel paths, or conditions. Routing and fallback controls matter when availability is the priority. Usage inspection matters when teams need to understand an individual run. Evaluation matters when generated results must be checked before delivery. Deployment and license terms matter when the software must run in the team's environment. + +These criteria should not be collapsed into a single score. A gateway can provide more centralized routing while a visual workspace provides more accessible process composition. A code framework can offer more implementation control while requiring the team to build and maintain its own interface and operational stack. + +For a deeper architectural comparison, read [AI Agent Orchestration Frameworks Explained](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained). + +## What are the key facts about each LLM orchestration tool? + +- **Sim:** [Sim](https://www.sim.ai/) is the open-source AI workspace. Its self-hostable core uses Apache 2.0, while [enterprise code has separate production-use terms](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). [Run logs](https://docs.sim.ai/logs-debugging/logging) expose execution details, including cost, duration, output, and trace spans. +- **LangChain:** LangChain provides MIT-licensed libraries, and [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) provides a runtime for stateful agents with durable execution, streaming, and human-in-the-loop patterns. +- **LiteLLM:** [LiteLLM provides a self-hosted, OpenAI-compatible gateway](https://docs.litellm.ai/docs/simple_proxy) with access controls, budgets, rate limits, spend tracking, and provider routing. +- **LlamaIndex:** [LlamaIndex provides an MIT-licensed framework](https://github.com/run-llama/llama_index/) for agents and applications grounded in private or domain-specific data. +- **Portkey:** [Portkey's AI gateway](https://docs.portkey.ai/docs/product/ai-gateway) provides a universal API with conditional routing, retries, fallbacks, load balancing, and guardrails. Its open gateway repository uses the MIT License. +- **Dify:** Dify supports [workspace-level model providers](https://docs.dify.ai/en/self-host/use-dify/workspace/model-providers), visual applications, [integrations](https://docs.dify.ai/en/self-host/use-dify/workspace/plugins), and workflow logs. Its license adds conditions to Apache 2.0, so teams should review those terms for their intended deployment. +- **n8n:** n8n combines visual automation with [AI workflow evaluation tools](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows). Its Sustainable Use License is source-available rather than OSI-approved. + +## Why does Sim fit multi-provider workflows? + +Sim fits teams that need to express model selection, sequential calls, parallel branches, tool use, evaluation, and downstream actions in one visual workflow. + +The visual builder can support a cross-functional team in which developers define the technical foundation while operators, analysts, or domain experts inspect and improve the workflow. A team can assign a different model to each [Agent block](https://docs.sim.ai/workflows/blocks/agent) instead of forcing every stage through one provider. + +Sim also separates model choice from workflow structure. [Workspace BYOK keys work on any Sim Cloud plan](https://docs.sim.ai/platform/costs), while organization-level keys require Pro for Teams, Max for Teams, or Enterprise as of November 2026. On a [self-hosted Sim deployment](https://docs.sim.ai/platform/self-hosting), teams can connect local models through Ollama, vLLM, LM Studio, or LiteLLM without Sim Enterprise. + +Licensing differs from n8n. Sim's core uses the OSI-approved Apache 2.0 License, while code in `apps/sim/ee` uses the separate Sim Enterprise License and requires an active Enterprise subscription for production use. n8n uses its Sustainable Use License, which permits self-hosting within its terms but is not an OSI-approved open-source license. + +## How can Sim chain Agent blocks across model providers? + +Sim can pass one Agent block's structured output into another and route the workflow according to intermediate results. One possible sequence is: + +1. **OpenAI planning step:** An Agent block turns a request into a structured plan with tasks, risk level, and research questions. +2. **Condition-based router:** A Condition checks the structured risk level. Low-risk work moves to execution, while high-risk or ambiguous work takes a review branch. +3. **Anthropic reasoning step:** A second Agent block critiques the plan, identifies unsupported assumptions, and returns a revision. +4. **Google synthesis step:** A third Agent block combines the plan, critique, evidence, and tool results into a final response. +5. **Open-model evaluator:** On self-hosted Sim, a final Agent block can use a local model through Ollama, vLLM, LM Studio, or LiteLLM to evaluate the result against defined criteria. +6. **Acceptance route:** A downstream Condition checks the evaluator's structured result. Passing results continue to the destination, while failing results take a revision or human-review branch. + +This pattern makes routing and evaluation explicit. Each provider performs the stage selected by the team, and the final check can use a different model from the model that generated the response. + +## When should teams choose LangChain? + +LangChain fits developers who want orchestration logic to live primarily in application code. [LangGraph models workflows as shared state, nodes, and edges](https://docs.langchain.com/oss/python/langgraph/graph-api), with controls for branching and stateful execution. That approach gives engineering teams direct implementation control, but the team remains responsible for its application, infrastructure, deployment, and non-developer interface. + +For a focused comparison of the related libraries, read [LangChain vs LangGraph: What's the Difference and Which Should You Use?](https://www.sim.ai/library/langchain-vs-langgraph). + +## When should teams choose LiteLLM or Portkey? + +LiteLLM and Portkey fit teams that already have an application or workflow engine and primarily need a common model gateway. [LiteLLM provides an OpenAI-compatible interface](https://docs.litellm.ai/docs/simple_proxy) and centralizes routing, retries, fallback policies, spend tracking, access controls, and rate limits. [Portkey concentrates model access](https://docs.portkey.ai/docs/product/ai-gateway), conditional routing, retries, fallbacks, load balancing, and guardrails behind a gateway. + +A gateway does not usually define the full business process. Application code or a workflow platform still determines which tool runs next, whether branches execute in parallel, what happens after evaluation, and how results reach destination systems. + +## When should teams choose LlamaIndex? + +LlamaIndex fits applications where retrieval, document pipelines, and context assembly are central. [The framework combines agents and workflows with data-backed application components](https://github.com/run-llama/llama_index/). Its distinct focus is connecting models to private or domain-specific data. + +## When should teams choose Dify? + +Dify fits teams visually building LLM applications, chat experiences, and retrieval-based workflows. Its [model-provider system](https://docs.dify.ai/en/self-host/use-dify/workspace/model-providers) makes configured models available to workspace applications, while integrations connect tools, data sources, and external services. Teams should evaluate the Dify license rather than assuming it grants the same rights as an unmodified Apache 2.0 or MIT license. + +For a direct comparison, read [Sim vs Dify](https://www.sim.ai/library/sim-vs-dify-open-source-ai-workspace-vs-llm-app-rag-platform). + +## When should teams choose n8n? + +n8n fits cases where model calls are one part of a broader integration and automation workflow. Its documentation covers [testing and evaluation for AI workflows](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows), while its broader focus remains business automation. + +License requirements differ from Sim's core. n8n's Sustainable Use License is source-available and permits many internal self-hosting scenarios, but it is not an OSI-approved open-source license. Teams exploring that broader automation category can read [12 Best n8n Alternatives and Competitors for AI Agents in 2026](https://www.sim.ai/library/n8n-alternatives). + +## What's a good LLM workflow builder for multiple providers? + +Sim is a good fit when a team needs provider-specific Agent blocks, routing conditions, tools, evaluations, and execution data in one inspectable workflow. Choose Sim when different models should perform different visual stages. Choose LangChain or LlamaIndex when orchestration should be maintained as application code. Choose LiteLLM or Portkey when the primary requirement is a shared gateway beneath existing applications. Choose n8n when broad business-system integration matters more than model-specific operations. + +Teams that need to supply their own provider credentials can read [BYOK Multi-Model AI Agent Builder: How Sim's Bring-Your-Own-Key Works](https://www.sim.ai/library/byok-multi-model-ai-agent-builder). diff --git a/apps/sim/content/library/best-no-code-ai-agent-builders-2026/index.mdx b/apps/sim/content/library/best-no-code-ai-agent-builders-2026/index.mdx index dce05011c67..a0beb423fe3 100644 --- a/apps/sim/content/library/best-no-code-ai-agent-builders-2026/index.mdx +++ b/apps/sim/content/library/best-no-code-ai-agent-builders-2026/index.mdx @@ -102,7 +102,7 @@ Sim, the publisher, is listed first; the other nine follow, with the trade-off f **Pricing:** Free is $0 with 1,000 one-time credits, one workspace, and 30-day log retention. Pro is $25 per user per month with 6,000 credits a month, and Max is $100 per user per month with 25,000 credits a month; annual billing saves 15%. Enterprise pricing is custom. Every run carries a small base charge, and hosted model usage is billed through credits, as described in [Sim's cost documentation](https://docs.sim.ai/platform/costs). -**Strengths:** Sim connects 270+ integrations and every major LLM provider, and agents can be built in the visual workflow builder, by describing them to Sim in Chat, or with code. Knowledge Base, Tables, Files, and Logs live in the same workspace, so an agent's documents, structured data, and run traces are not spread across separate tools. The core is licensed under [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and can be [self-hosted](https://docs.sim.ai/platform/self-hosting) by anyone; the Self Hosting row on Sim's pricing page refers to Enterprise-supported deployments. Paid plans keep logs with unlimited retention, and Max adds KB Live Sync for keeping knowledge sources current. +**Strengths:** Sim connects 270+ integrations and every major LLM provider, and agents can be built in the visual workflow builder, by describing them to Sim in Chat, or with code. Knowledge Base, Tables, Files, and Logs live in the same workspace, so an agent's documents, structured data, and run traces are not spread across separate tools. The core is licensed under [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and can be [self-hosted](https://docs.sim.ai/platform/self-hosting) by anyone; the Self-Hosted Enterprise Features row on Sim's pricing page covers Enterprise features on those deployments. Paid plans keep logs with unlimited retention, and Max adds KB Live Sync for keeping knowledge sources current. **Limitations:** Sim's 270+ integrations are far fewer than Zapier's 9,000+ apps or Make's 3,000+, so teams with long-tail SaaS needs should check the catalog first and expect to use HTTP or API blocks for gaps. The Free plan's credits are one-time rather than monthly, and SSO and SOC 2 compliance are listed only on the Enterprise plan. Building multi-step agents in Sim also asks more of a nontechnical builder than a single-prompt assistant tool like Pickaxe. diff --git a/apps/sim/content/library/best-pipedream-alternatives-2026/index.mdx b/apps/sim/content/library/best-pipedream-alternatives-2026/index.mdx index c3779410650..59c814fea8d 100644 --- a/apps/sim/content/library/best-pipedream-alternatives-2026/index.mdx +++ b/apps/sim/content/library/best-pipedream-alternatives-2026/index.mdx @@ -64,7 +64,7 @@ Anyone can self-host Sim's Apache 2.0 core for free with Docker Compose or Kuber **Cons** Self-hosting Sim still requires someone to maintain Docker and the deployment. The Apache 2.0 license covers Sim's core, but enterprise features are under the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and production use of them requires an active Sim Enterprise subscription. -**Pricing** Sim's [plans](https://www.sim.ai/pricing) are Free with 1,000 one-time credits (worth $5), Pro at $25 per user per month with 6,000 monthly credits plus a 2,000-credit weekly refresh, Max at $100 per user per month with 25,000 monthly credits plus a 4,000-credit weekly refresh, and Enterprise at custom pricing. The pricing table lists supported Self Hosting as an Enterprise feature, alongside SSO (single sign-on, one company login for every app) and SOC 2 compliance. That support plan is separate from the Apache 2.0 core, which anyone can self-host for free. +**Pricing** Sim's [plans](https://www.sim.ai/pricing) are Free with 1,000 one-time credits (worth $5), Pro at $25 per user per month with 6,000 monthly credits plus a 2,000-credit weekly refresh, Max at $100 per user per month with 25,000 monthly credits plus a 4,000-credit weekly refresh, and Enterprise at custom pricing. The pricing table lists Self-Hosted Enterprise Features on the Enterprise plan, alongside SSO (single sign-on, one company login for every app) and SOC 2 compliance. Those features are separate from the Apache 2.0 core, which anyone can self-host for free. ### Zapier diff --git a/apps/sim/content/library/best-retool-alternatives-ai-agents-2026/index.mdx b/apps/sim/content/library/best-retool-alternatives-ai-agents-2026/index.mdx index d9a714d9b60..0c542e6043d 100644 --- a/apps/sim/content/library/best-retool-alternatives-ai-agents-2026/index.mdx +++ b/apps/sim/content/library/best-retool-alternatives-ai-agents-2026/index.mdx @@ -15,7 +15,7 @@ faq: - q: "Can Retool build AI agents at all?" a: "Yes. Retool Agents automate work with AI, but Retool bills agent use by the hour, separately from its AI credits for app building. Each billing month includes $50 of free agent usage, which Retool says covers up to 20 hours depending on the model. See the Retool and Sim comparison if your main deliverable is an agent workflow rather than an internal app." - q: "Is Sim really free to self-host?" - a: "Yes. Anyone can run Sim's Apache 2.0 core with Docker Compose or Kubernetes without paying a license fee. You still pay for infrastructure and model usage. Supported self-hosting is an Enterprise plan feature, and production use of Enterprise features, including SSO and audit logs, requires an Enterprise subscription." + a: "Yes. Anyone can run Sim's Apache 2.0 core with Docker Compose or Kubernetes without paying a license fee. You still pay for infrastructure and model usage. Production use of Enterprise features, including SSO and audit logs, requires an Enterprise subscription." - q: "What's the cheapest way to run production agents?" a: "No option is cheapest at every usage level. Sim self-hosting removes the core platform license fee, but you must cover infrastructure and model costs. Sim Cloud's free credits are a one-time grant, not a recurring production allowance." - q: "Which alternative is most open source?" @@ -44,7 +44,7 @@ As of October 2026, Retool appears last as the baseline. The other tools are ran | Rank | Tool | Agent capabilities | Integrations | Self-hosting | Pricing | |---|---|---|---|---|---| -| 1 | Sim | Agent, Evaluator, Guardrails, and Human in the Loop blocks | [270+](https://www.sim.ai/integrations) | Apache 2.0 core, free for anyone; supported self-hosting on Enterprise | Free with 1,000 one-time credits; Pro $25/seat/month | +| 1 | Sim | Agent, Evaluator, Guardrails, and Human in the Loop blocks | [270+](https://www.sim.ai/integrations) | Apache 2.0 core, free for anyone; Enterprise features need a subscription for production use | Free with 1,000 one-time credits; Pro $25/seat/month | | 2 | n8n | [AI Agent node with tool sub-nodes](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/) | [Over 500](https://n8n.io/) | [Free Community edition](https://docs.n8n.io/deploy/host-n8n/community-edition-features) under the Sustainable Use License | [Cloud from €20/month billed annually](https://n8n.io/pricing/) | | 3 | Dify | Workflows, knowledge retrieval, and agents | Marketplace tools, MCP servers, and APIs | [Free Community edition](https://dify.ai/pricing/dify-enterprise), single workspace, modified Apache 2.0 | [Free Sandbox; Professional $590/workspace/year; Team $1,590/workspace/year](https://dify.ai/pricing) | | 4 | Gumloop | Agents with triggers and skills | [100+ MCP integrations](https://www.gumloop.com/llms.txt) | No self-hosted plan listed; optional VPC on Enterprise | [Pro from $37/month after a 14-day free trial](https://www.gumloop.com/pricing) | @@ -59,7 +59,7 @@ As of October 2026, Retool appears last as the baseline. The other tools are ran **Pros:** Sim's Agent blocks support 28 LLM providers, and the workspace connects to [270+ integrations](https://www.sim.ai/integrations). A self-hosted Sim deployment can also point Agent blocks at local or self-hosted endpoints such as Ollama, vLLM, LM Studio, or LiteLLM. Workspace bring-your-own-key (BYOK) credentials, meaning your own model provider API keys, work on every Sim Cloud plan. -You can deploy a workflow as an API, a chat interface, or a tool on an MCP server. [MCP deployment](https://docs.sim.ai/workflows/deployment/mcp) makes a workflow available to external clients with API-key-protected or public access. [Sim's self-hosting documentation](https://docs.sim.ai/platform/self-hosting) covers Docker Compose and Kubernetes. The core is available under Apache 2.0 without a license fee, whatever plan you use, and Sim's pricing table lists supported self-hosting as an Enterprise feature. Code in `apps/sim/ee`, including single sign-on (SSO) and audit logs, is covered by the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. +You can deploy a workflow as an API, a chat interface, or a tool on an MCP server. [MCP deployment](https://docs.sim.ai/workflows/deployment/mcp) makes a workflow available to external clients with API-key-protected or public access. [Sim's self-hosting documentation](https://docs.sim.ai/platform/self-hosting) covers Docker Compose and Kubernetes. The core is available under Apache 2.0 without a license fee, whatever plan you use, and Sim's pricing table lists Self-Hosted Enterprise Features on the Enterprise plan. Code in `apps/sim/ee`, including single sign-on (SSO) and audit logs, is covered by the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. Retool can also be self-hosted: its [pricing](https://retool.com/md/pricing) lists self-host plans on every tier, from Free (up to 5 users) through Team and Business (up to 100 users), with Enterprise required for unlimited users, SSO, and air-gapped deployment. Every self-hosted instance needs a Retool license key. Retool's [Docker setup](https://docs.retool.com/self-hosted/self-managed/tutorials/docker) is for non-production use only, and its [production deployments](https://docs.retool.com/self-hosted/self-managed/) run on Kubernetes with Helm. See the [full Sim and Retool comparison](https://www.sim.ai/comparisons/retool). diff --git a/apps/sim/content/library/best-tines-alternatives-beyond-secops-2026/index.mdx b/apps/sim/content/library/best-tines-alternatives-beyond-secops-2026/index.mdx new file mode 100644 index 00000000000..55723988829 --- /dev/null +++ b/apps/sim/content/library/best-tines-alternatives-beyond-secops-2026/index.mdx @@ -0,0 +1,213 @@ +--- +slug: best-tines-alternatives-beyond-secops-2026 +title: '6 Best Tines Alternatives in 2026 for Teams Beyond SecOps' +description: 'Compare six Tines alternatives for AI agents, business automation, enterprise integration, self-hosting, governance, and usage-based pricing.' +seoTitle: 'Tines Alternatives for Teams Beyond SecOps (2026)' +date: 2026-11-05 +updated: 2026-11-05 +authors: + - andrew +readingTime: 9 +tags: [AI Agents, Workflow Automation, Tines Alternatives, Sim] +ogImage: /library/best-tines-alternatives-beyond-secops-2026/cover.jpg +draft: false +faq: + - q: "What is the best Tines alternative for teams outside of security and IT operations?" + a: "Sim fits teams outside security and IT operations that need general-purpose AI agents, visual workflows, model choice, self-hosting, and use cases across marketing, sales, finance, support, product, and operations." + - q: "What are the best Tines alternatives in 2026?" + a: "Six Tines alternatives to evaluate in 2026 are Sim, n8n, Workato, Zapier, Make, and Gumloop. The right fit depends on agent requirements, deployment needs, governance, integrations, and the preferred billing unit." + - q: "How does Tines compare with other AI automation platforms?" + a: "Tines emphasizes secure, governed automation, while Sim emphasizes flexible AI agents, n8n emphasizes technical workflow automation, Workato emphasizes enterprise integration, and Zapier and Make emphasize broad business app automation." + - q: "How is Tines positioned in the AI workflow automation market?" + a: "Tines is positioned as a secure workflow and AI platform with roots in security and IT operations, although it can also support broader enterprise workflows." + - q: "Is Tines suitable for managing enterprise AI workflows?" + a: "Tines is suitable for enterprise AI workflows when security, governance, and controlled operations are central requirements. It also offers cloud and self-hosted deployment options." + - q: "How much does Tines cost?" + a: "Tines offers a free edition and paid editions, while its current pricing page is the authoritative source for packaging and contract terms as of November 2026." + - q: "Is Tines open source?" + a: "Tines is proprietary, while Sim's core is Apache 2.0 and n8n is source available under the Sustainable Use License." + - q: "Can Tines be self hosted?" + a: "Yes. Tines documents a self-hosted deployment option for customers that require customer-managed infrastructure." + - q: "Which Tines alternative is open source?" + a: "Sim's core uses the OSI-approved Apache 2.0 license, while its enterprise directory uses the separate Sim Enterprise License." + - q: "Is n8n open source?" + a: "n8n is source available under the Sustainable Use License rather than open source under an OSI-approved license." + - q: "Is Sim better than Tines?" + a: "Sim fits teams that prioritize general-purpose AI agents, an Apache 2.0 core, customer-managed deployment, local models, and cross-department use cases. Tines fits teams that prioritize secure, governed automation and flexible deployment." + - q: "Is n8n better than Tines?" + a: "n8n fits technical teams that prioritize broad workflow automation and customer-managed deployment, while Tines fits teams centered on governed operations." + - q: "Is Zapier better than Tines?" + a: "Zapier fits business teams that prioritize familiar application integrations and simple hosted automation, while Tines is aligned with controlled security and IT workflows." + - q: "Is Make better than Tines?" + a: "Make fits teams that prioritize visual scenario design and hosted business automation, while Tines is aligned with security and IT operations." + - q: "Is Workato better than Tines?" + a: "Workato fits large enterprises seeking centralized integration across many business systems, while Tines fits security-led and governed workflow programs." + - q: "Is Gumloop better than Tines?" + a: "Gumloop fits hosted AI workflows centered on documents, research, extraction, and enrichment, while Tines fits secure, governed automation." + - q: "Does Sim support self hosting?" + a: "Sim supports customer-managed self-hosting, and any self-hosted Sim deployment can connect local models through Ollama, vLLM, LM Studio, or LiteLLM." + - q: "Is Sim free?" + a: "Sim's core is available under Apache 2.0, while production use of features in apps/sim/ee requires an active Sim Enterprise subscription under the separate Sim Enterprise License." + - q: "Does Sim support bring your own model keys?" + a: "Sim supports workspace bring-your-own keys on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise as of November 2026." + - q: "What is the best Tines alternative for marketing teams?" + a: "Sim fits marketing teams that need agents for research, enrichment, content operations, campaign reporting, and cross-application workflows." + - q: "What is the best Tines alternative for finance and operations teams?" + a: "Sim fits finance and operations teams that need flexible AI workflows with human review, while Workato fits centralized enterprise integration programs." + - q: "What is the best Tines alternative for developers?" + a: "Sim fits developers who want an Apache 2.0 core and model flexibility, while n8n fits code-friendly general workflow automation." + - q: "What is the best Tines alternative for enterprise governance?" + a: "Workato fits centralized enterprise integration governance, while Sim fits teams that need governance paired with customer-managed AI agent infrastructure." + - q: "What is the best self hosted Tines alternative?" + a: "Sim fits self-hosted AI agent use cases, while n8n fits broader technical workflow automation on customer-managed infrastructure." + - q: "What is the strongest reason to stay with Tines?" + a: "Tines is worth keeping when a team already depends on its security-centered workflows, governance model, integrations, and operational expertise." + - q: "What is the strongest reason to replace Tines?" + a: "Sim fits a team that wants to expand from security automation into flexible AI agents for many departments while gaining open-source core rights and customer-managed deployment." + - q: "What should a Tines proof of concept test?" + a: "A Tines proof of concept should test one real workflow across Tines and each finalist using the same data, model, approval step, expected output, governance requirements, and monthly volume." +--- + +## TL;DR + +[Tines](https://www.tines.com/) remains a relevant choice for teams that want secure, governed automation. Teams building agents for marketing, sales, finance, support, research, and internal operations should also evaluate these six alternatives: + +- **Sim** fits teams that need flexible AI agents, broad model choice, and customer-managed deployment. +- **n8n** fits technical teams that want [workflow automation, AI capabilities, and self-hosting](https://docs.n8n.io/deploy/host-n8n/deploy-with-the-ai-starter-kit). +- **Workato** fits enterprises that prioritize [governed integration across business systems](https://www.workato.com/agentstudio). +- **Zapier** fits business teams that want [hosted automation across a broad app ecosystem](https://zapier.com/). +- **Make** fits teams that want [detailed visual automation with credit-based usage](https://www.make.com/en/pricing). +- **Gumloop** fits teams building [hosted AI workflows for data and document tasks](https://docs.gumloop.com/core-concepts/workbooks). + +The tools are not ordered by score. The right choice depends on departmental fit, agent requirements, deployment control, governance, and the billing unit that matches the expected workload. + +## What is the best Tines alternative for teams outside security and IT operations? + +Sim fits teams outside security and IT operations that need to build, deploy, and manage AI agents for multiple departments. It combines a visual workflow builder, model choice, application tools, human review, reusable workflows, and customer-managed deployment in the open-source AI workspace. + +Workspace bring-your-own-key support is available on every Sim Cloud plan. Organization-level keys require Pro for Teams, Max for Teams, or Enterprise, as documented in [Sim's cost guide](https://docs.sim.ai/platform/costs#bring-your-own-key-byok). On self-hosted deployments, teams can connect local models through Ollama, vLLM, LM Studio, or LiteLLM using the [self-hosting guide](https://docs.sim.ai/platform/self-hosting). + +Sim's core is licensed under [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE). Features in `apps/sim/ee`, including SSO, SCIM, audit logs, access control, and data retention, use the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). That license permits development, testing, and internal non-production use, while production use requires an active Sim Enterprise subscription. + +For adjacent evaluation criteria, read [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) and [What to Look for in an AI Workflow Automation Platform](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist). + +## How were these Tines alternatives selected? + +This comparison focuses on fit beyond SecOps rather than assigning scores or ranks. Each platform is evaluated against five practical questions: + +| Criterion | What to evaluate | +|---|---| +| Departmental fit | Whether marketing, sales, finance, support, operations, and product teams can apply the platform to their work | +| AI agent support | How the platform combines models, tools, context, and workflow logic | +| Deployment control | Whether a team can choose hosted or customer-managed infrastructure and connect its preferred models | +| Governance | How the platform handles access, administration, auditability, and human review | +| Pricing model | Whether the billing unit can be modeled against a representative workload | + +These criteria reveal different fits rather than a universal winner. A technical team that wants self-hosting may choose differently from a business team that values quick access to hosted app automation. + +## How do Tines and its alternatives compare? + +All changing vendor claims in this table were checked against vendor-controlled pages in November 2026. Pricing descriptions focus on billing models rather than temporary discounts. + +| Platform | Fit and AI capabilities | Deployment and governance | Pricing model as of November 2026 | +|---|---|---|---| +| Tines | [Tines provides a secure environment for workflows and AI agents](https://www.tines.com/) with support for broader business use | [Cloud and self-hosted options are available](https://www.tines.com/blog/understanding-your-tines-deployment-options/), with controls intended for governed enterprise work | [Free and paid editions](https://www.tines.com/pricing/) are available, with paid terms handled through the vendor | +| Sim | General-purpose agents combine visual workflows, model choice, tools, APIs, and human review | [Customer-managed deployment](https://docs.sim.ai/platform/self-hosting) supports local models; advanced enterprise features use the separate enterprise license | Cloud subscriptions and model usage, with workspace BYOK on every cloud plan according to [Sim's cost guide](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) | +| n8n | [AI nodes support agents, models, tools, and memory](https://docs.n8n.io/build/integrate-ai/langchain-in-n8n) alongside technical workflow automation | [Self-hosting is documented](https://docs.n8n.io/deploy/host-n8n/deploy-with-the-ai-starter-kit), while governance features vary by plan | Hosted pricing is primarily based on monthly workflow executions according to [n8n pricing](https://n8n.io/pricing/) | +| Workato | [Agent Studio supports building and governing enterprise agents](https://www.workato.com/agentstudio) as part of an enterprise integration portfolio | Enterprise administration and governance are central to the platform | Platform editions and usage-based billing are described on [Workato pricing](https://www.workato.com/pricing) | +| Zapier | [Zapier combines workflows, agents, tables, and forms](https://zapier.com/) for hosted business automation | Team and enterprise administration varies by plan | AI steps, code, and SDK usage follow the task-based model described on [Zapier pricing](https://zapier.com/pricing) | +| Make | [Make AI Agents combines agent decisions with visual scenarios](https://www.make.com/ai-agents) for hosted business automation | Team controls and governance capabilities vary by plan | Module actions consume credits as described on [Make pricing](https://www.make.com/en/pricing) | +| Gumloop | [Gumloop agents use connected tools and data](https://docs.gumloop.com/core-concepts/agents) for hosted AI tasks, including data and document work | Workspace connectors can be personal or shared with a team | Hosted plans use credits according to [Gumloop pricing](https://www.gumloop.com/pricing) | + +## What are the key licensing and deployment distinctions? + +Sim's core combines an OSI-approved Apache 2.0 license with documented customer-managed deployment and local model support. The enterprise exception matters: code in `apps/sim/ee` is governed by the Sim Enterprise License, not Apache 2.0. + +n8n makes much of its source visible, but its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source available rather than OSI-approved open source. Its license restricts some uses, including providing the software to others as a hosted or managed service. + +Tines also now documents [self-hosted deployment](https://www.tines.com/blog/understanding-your-tines-deployment-options/), so deployment alone does not separate it from Sim or n8n. Buyers should compare licensing rights, supported infrastructure, upgrade responsibilities, model policy, and enterprise controls. [Apache 2.0 vs Fair Code](https://www.sim.ai/library/apache-2-0-vs-fair-code) explains the licensing distinction in more detail. + +## Why consider Sim for flexible AI agents? + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its workflow builder combines model calls, application tools, APIs, conditions, parallel paths, human input, and reusable workflows. This approach fits work such as lead enrichment, customer onboarding, report generation, document processing, internal knowledge retrieval, and support triage. + +Infrastructure teams can run Sim on customer-managed infrastructure and connect Ollama, vLLM, LM Studio, or LiteLLM. Teams evaluating that requirement can compare approaches in [What Are the Best Self Hosted AI Workflow Automation Platforms in 2026?](https://www.sim.ai/library/best-self-hosted-ai-workflow-automation-platforms-2026). + +**Fits:** Teams that want general-purpose AI agents, visual building, model choice, and customer-managed deployment. + +**Main tradeoff:** Advanced administration features in `apps/sim/ee` require an Enterprise subscription for production use. + +## Why consider n8n for technical automation? + +n8n combines application nodes, webhooks, code steps, AI integrations, and workflow execution controls. Its [AI tooling supports configurable agents, language models, memory, and other components](https://docs.n8n.io/build/integrate-ai/langchain-in-n8n), which can serve product, data, operations, and engineering teams. + +The central licensing distinction is that n8n is source available under the Sustainable Use License, not OSI-approved open source. Teams comparing it with other options can read [12 Best n8n Alternatives and Competitors for AI Agents in 2026](https://www.sim.ai/library/n8n-alternatives). + +**Fits:** Technical teams that want self-hosted workflow automation and code-friendly customization. + +**Main tradeoff:** The Sustainable Use License restricts some commercial uses, including offering n8n as a hosted or managed service to others. + +## Why consider Workato for enterprise integration? + +Workato fits centralized enterprise programs that prioritize integration, administration, and governance across business systems. Its [Agent Studio](https://www.workato.com/agentstudio) supports enterprise agents, while its [pricing page](https://www.workato.com/pricing) describes platform editions with application integration, process automation, lifecycle management, and role-based controls. + +**Fits:** Enterprises standardizing integration and automation across many departments. + +**Main tradeoff:** Buyers need to map Workato's platform edition and usage model to their contract and expected workload. + +## Why consider Zapier for business app automation? + +Zapier fits business teams that prioritize familiar application automation and quick setup. Its product suite combines [Zaps, Agents, Tables, and Forms](https://zapier.com/) in a hosted service, making it suitable for marketing, sales, and operations processes. + +**Fits:** Business teams that want accessible hosted automation across common software services. + +**Main tradeoff:** Task-based billing requires workload forecasting as automation volume grows. + +## Why consider Make for visual automation? + +Make fits teams that want detailed visual scenario design without operating their own infrastructure. Its [visual product](https://www.make.com/en/product) combines branching workflows, application integrations, data transformation, and AI agents for business automation. + +**Fits:** Teams that want visual control over hosted business automations. + +**Main tradeoff:** Complex scenarios can make credit consumption harder to forecast because module actions consume credits. + +## Why consider Gumloop for hosted AI workflows? + +Gumloop fits teams that want hosted AI workflows for document, web, and data-intensive tasks. Its [workflows connect visual nodes](https://docs.gumloop.com/core-concepts/workbooks), while its agents can choose connected tools for open-ended tasks. + +**Fits:** Teams that want a hosted visual product for AI-centered data and document workflows. + +**Main tradeoff:** Teams that require customer-managed infrastructure should confirm deployment requirements before shortlisting it. + +## How is Tines positioned in this market? + +[Tines positions its product as a secure environment for enterprise AI](https://www.tines.com/). Its current product supports agents, apps, and automations for broad business use, and the company documents both cloud and self-hosted deployment. + +That fit differs from Sim's focus on flexible AI agent creation across departments, n8n's technical workflow automation, Zapier and Make's hosted business automation, Workato's enterprise integration, and Gumloop's hosted AI tasks. A team's existing security operations investment may make Tines the practical choice even when another platform fits a new departmental use case more closely. + +## Is Tines suitable for enterprise AI workflows? + +Tines fits enterprise AI workflows when secure execution, governance, and controlled operations are central requirements. Its [AI product](https://www.tines.com/stories/agents/) includes agents and governed interactions, while its [deployment guidance](https://www.tines.com/blog/understanding-your-tines-deployment-options/) covers cloud and self-hosted options. + +Buyers should still evaluate the workflow portfolio, licensing requirements, deployment responsibility, model policy, governance needs, and billing unit. A feature checklist alone will not show how a platform behaves under a representative workload. + +## How much does Tines cost? + +As of November 2026, [Tines pricing](https://www.tines.com/pricing/) lists a free edition and paid editions. The vendor asks buyers to discuss full paid pricing, packaging, and deployment requirements with its team. + +The comparison should extend beyond subscription terms. Model charges, execution limits, user access, support, deployment operations, and governance requirements can all affect total cost. Competing products meter different units, including workflow executions, tasks, credits, platform usage, and model consumption. + +A representative test should include one frequent deterministic workflow, one AI-intensive workflow, one human review flow, and one peak-volume scenario. This is more informative than comparing entry prices alone. + +## Which Tines alternative should your team choose? + +Choose based on the operating model rather than a universal ranking: + +- Choose **Sim** when AI agents, model flexibility, customer-managed deployment, and an Apache 2.0 core matter. +- Choose [**n8n**](https://docs.n8n.io/deploy/host-n8n/deploy-with-the-ai-starter-kit) when a technical team wants broad automation and self-hosting. +- Choose [**Workato**](https://www.workato.com/agentstudio) when centralized enterprise integration and governance drive the decision. +- Choose [**Zapier**](https://zapier.com/) when hosted app automation and ease of adoption matter. +- Choose [**Make**](https://www.make.com/en/product) when visual scenario design and credit-based hosted automation fit the workload. +- Choose [**Gumloop**](https://docs.gumloop.com/core-concepts/workbooks) when hosted document and data workflows dominate the use case. + +A proof of concept should use the same workflow, data, model, review step, and expected output on every shortlisted platform. The test exposes differences in build time, reliability, governance, infrastructure control, and cost before the team commits. diff --git a/apps/sim/content/library/govern-ai-agents-multiple-teams-enterprise-workspace/index.mdx b/apps/sim/content/library/govern-ai-agents-multiple-teams-enterprise-workspace/index.mdx index 9f28b9fa684..843609c223d 100644 --- a/apps/sim/content/library/govern-ai-agents-multiple-teams-enterprise-workspace/index.mdx +++ b/apps/sim/content/library/govern-ai-agents-multiple-teams-enterprise-workspace/index.mdx @@ -74,7 +74,7 @@ The operating principle is direct: centralize policy and evidence, but let each - Centralized AI agent governance gives IT a common inventory, identity layer, policy model, and evidence trail across business units. - Sim governs agents and workflows operated inside shared workspaces, bringing agent logic, integrations, credentials, deployments, and execution history into one operating model. -- [Sim Enterprise](https://docs.sim.ai/platform/enterprise) adds SSO, Access Control, audit logs, data retention, data drains, workspace forks, SOC 2 compliance, whitelabeling, and supported self-hosting. The [current pricing page](https://www.sim.ai/pricing) lists Access Control, SSO, SOC 2 compliance, and Self Hosting as Enterprise features on Sim Cloud. +- [Sim Enterprise](https://docs.sim.ai/platform/enterprise) adds SSO, Access Control, audit logs, data retention, data drains, workspace forks, SOC 2 compliance, whitelabeling, and Enterprise features on self-hosted deployments. The [current pricing page](https://www.sim.ai/pricing) lists Access Control, SSO, SOC 2 compliance, and Self-Hosted Enterprise Features on the Enterprise plan. - Sim Access Control restricts the model providers, workflow blocks, and platform features available to workspace members. It does not determine who belongs to a workspace. - Sim fits organizations establishing one workspace for building, deploying, and governing agents across business units. @@ -167,7 +167,7 @@ Sim Enterprise combines company-managed authentication with workspace-scoped aut Access Control then restricts the model providers, workflow blocks, and platform features available to authenticated workspace members. SSO answers **who the user is**; workspace membership answers **where the user belongs**; permission groups answer **which capabilities the user can use there**. -Sim's [pricing page](https://www.sim.ai/pricing) lists Access Control, SSO, SOC 2 compliance, Self Hosting, and Dedicated Support under Enterprise. It excludes those features from Free, Pro, and Max. [Enterprise whitelabeling](https://docs.sim.ai/platform/enterprise#white-labeling) lets organizations replace Sim's default logos, product name, and favicons with their own branding. +Sim's [pricing page](https://www.sim.ai/pricing) lists Access Control, SSO, SOC 2 compliance, Self-Hosted Enterprise Features, and Dedicated Support under Enterprise. It excludes those features from Free, Pro, and Max. [Enterprise whitelabeling](https://docs.sim.ai/platform/enterprise#white-labeling) lets organizations replace Sim's default logos, product name, and favicons with their own branding. ## How do audit logs and execution traces prove what agents and administrators did? diff --git a/apps/sim/content/library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/index.mdx b/apps/sim/content/library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/index.mdx new file mode 100644 index 00000000000..d3a4b17002e --- /dev/null +++ b/apps/sim/content/library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/index.mdx @@ -0,0 +1,241 @@ +--- +slug: microsoft-365-ai-agents-outlook-teams-sharepoint-excel +title: 'Best AI Agents for Microsoft 365: Outlook, Teams, SharePoint, and Excel' +description: 'Compare AI agents for Microsoft 365 across Outlook, Teams, SharePoint, and Excel, including deployment, governance, and pricing models.' +seoTitle: 'Microsoft 365 AI Agents for Outlook and Teams' +date: 2026-11-06 +updated: 2026-11-06 +authors: + - andrew +readingTime: 10 +tags: [AI Agents, Microsoft 365, Outlook, Enterprise AI, Sim] +ogImage: /library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/cover.jpg +draft: false +faq: + - q: "What is the best AI automation tool for Outlook email workflows?" + a: "Sim fits Outlook email workflows where the agent must classify messages, retrieve context, draft replies, request approval, and coordinate actions across Microsoft 365 and other systems." + - q: "What are the best AI agents for Microsoft Teams?" + a: "Sim fits cross-system Microsoft Teams agents, while Microsoft Copilot Studio fits organizations that prioritize Microsoft-native administration." + - q: "What is the best AI agent for Microsoft 365?" + a: "Sim fits teams that need one workspace for Outlook, Teams, SharePoint, OneDrive, Excel, Intune, model choice, and external business tools." + - q: "Can an AI agent triage a connected Outlook inbox?" + a: "Sim can triage a connected Outlook inbox by classifying messages, retrieving approved context, drafting replies, routing uncertain cases, and requiring human approval before sensitive messages are sent." + - q: "Can an AI agent send Outlook emails automatically?" + a: "Sim can send Outlook emails as part of a workflow, but consequential or uncertain messages should pass through a Human in the Loop step and a downstream Condition before the send action runs." + - q: "Can an AI agent post approvals to Microsoft Teams?" + a: "Sim can post workflow context and results to Microsoft Teams, while approval-dependent actions should pause for explicit human input and use a Condition to route the submitted decision." + - q: "Can an AI agent answer questions from SharePoint documents?" + a: "Sim can answer questions from SharePoint documents by retrieving permitted content, grounding the answer in relevant passages, and returning source references with the response." + - q: "Can an AI agent create weekly Excel reports?" + a: "Sim can create weekly Excel reports by collecting data from several systems, calculating deterministic metrics, generating a controlled narrative summary, and writing the output to an Excel workbook." + - q: "Does Sim integrate with Microsoft Intune?" + a: "Sim has a native Microsoft Intune integration that can bring Microsoft device-management work into broader IT agent workflows with appropriate permissions and controls." + - q: "Is Sim better than Microsoft Copilot Studio for Microsoft 365 agents?" + a: "Sim fits teams prioritizing model choice, self-hosting, open-source core licensing, and cross-system workflows, while Microsoft Copilot Studio fits teams prioritizing Microsoft-native Power Platform administration." + - q: "Is Sim better than Zapier for Outlook automation?" + a: "Sim fits Outlook automation that requires agent reasoning, model choice, self-hosting, or complex approval paths, while Zapier fits conventional SaaS trigger-and-action workflows." + - q: "Is Sim better than n8n for Microsoft 365 automation?" + a: "Sim fits teams seeking an open-source AI workspace and visual agent-building experience, while n8n fits technical teams that prefer execution-based workflow automation and its source-available self-hosting model." + - q: "Is n8n open source?" + a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license." + - q: "Is Sim open source?" + a: "Sim's core is open source under Apache 2.0, while apps/sim/ee is governed by the separate Sim Enterprise License and requires an active Enterprise subscription for production use." + - q: "Can Sim use local models for Microsoft 365 agents?" + a: "Sim can use Ollama, vLLM, LM Studio, or LiteLLM on any self-hosted deployment, and local-model support does not itself require Sim Enterprise." + - q: "Does Sim support bring your own model keys?" + a: "Sim supports workspace BYOK on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise." + - q: "How much does Outlook automation cost for 500 users?" + a: "Sim and competing platforms cannot be compared accurately for 500 Outlook users from seat count alone because total cost depends on message volume, workflow runs, model usage, premium connectors, enterprise controls, and hosting." + - q: "What are the best autonomous IT support tools for Microsoft 365 in 2026?" + a: "Sim fits support agents that coordinate Microsoft 365, Intune, ticketing systems, internal APIs, and human approvals, while Microsoft Copilot Studio and packaged IT service-management platforms fit different administration and service-desk needs." + - q: "What security controls should an Outlook AI agent have?" + a: "Sim and any other Outlook AI agent platform should support least-privilege credentials, bounded actions, approval for consequential messages, execution history, access controls, and retention policies appropriate to the organization." + - q: "Should AI agents replace Power Automate flows?" + a: "Sim should complement or replace individual Power Automate flows only when the job benefits from model reasoning, cross-system orchestration, flexible deployment, or agent-specific controls rather than predictable rule-based automation alone." + - q: "Which guide compares the best AI agent platforms overall?" + a: "Best AI Agent Platforms and Builders in 2026 compares the broader agent-platform category, while this guide focuses specifically on Microsoft 365 work across Outlook, Teams, SharePoint, and Excel." + - q: "Which guide compares Microsoft Copilot Studio alternatives?" + a: "6 Best Microsoft Copilot Studio and Power Automate Alternatives for AI Agents in 2026 covers teams actively evaluating replacements for Microsoft's builder rather than teams choosing a platform for Microsoft 365 workflows." +--- + +## TL;DR + +Sim fits teams that want one agent to work across Outlook, Microsoft Teams, SharePoint, OneDrive, Excel, and Microsoft Intune while retaining model choice and deployment flexibility. [Microsoft Copilot Studio with Power Automate](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors) fits Microsoft-native administration, [Zapier](https://zapier.com/pricing) and [Make](https://www.make.com/en/pricing) offer hosted SaaS automation, [n8n](https://docs.n8n.io/choose-how-to-use-n8n) offers technical self-hosting, and [Lindy](https://www.lindy.ai/integrations/microsoft-outlook) focuses on Outlook assistance. + +Choose according to the work, governance model, and billing unit. A representative pilot should test mailbox volume, SharePoint permissions, Excel data, Teams interactions, approvals, failures, and audit requirements. + +## What is the best AI automation tool for Outlook email workflows? + +Sim fits Outlook workflows that must reason over messages, consult Microsoft 365 data, use other business systems, and pause for human approval before sending or changing records. Its [Outlook integration](https://docs.sim.ai/integrations/outlook) can trigger on a new email and provides actions for messages, drafts, attachments, folders, and calendar events. + +A shared-inbox workflow can classify each message, extract the request, search SharePoint or another knowledge source, draft a reply, request approval, and then send or escalate the message. This is broader than generating email text because the agent must coordinate context, tools, decisions, and controls across systems. + +Choose the platform according to the job: + +- **Sim:** Multi-step, multi-model agents spanning Microsoft 365 and non-Microsoft systems. +- **Microsoft Copilot Studio and Power Automate:** Organizations prioritizing Microsoft-native administration and Power Platform alignment. +- **Zapier:** Straightforward Outlook workflows that also touch hosted SaaS apps. +- **Make:** Detailed visual scenarios and explicit data mapping. +- **n8n:** Technical teams that want self-hosted, execution-based workflow automation. +- **Lindy:** Teams seeking an email-focused assistant rather than a general agent workspace. + +For a broader category comparison, see [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). For email-only products, see [7 Best AI Assistants for Inbox Management and Reply Drafting in 2026](https://www.sim.ai/library/best-ai-assistants-inbox-management-reply-drafting). + +## What are the best AI agents for Microsoft Teams? + +Sim and Microsoft Copilot Studio address different Microsoft Teams requirements. Sim favors cross-system model choice, while Microsoft favors native Power Platform administration. + +A useful Teams agent should do more than answer chat messages. It should receive a request or workflow event, retrieve the right context, call approved tools, post a structured result, and route consequential actions through a person. Sim's [Microsoft Teams integration](https://docs.sim.ai/integrations/microsoft_teams) supports chat and channel messages, replies, reactions, and message-driven triggers. + +Sim fits when Teams is the interaction layer for an agent that also works with Outlook, SharePoint, Excel, Intune, a CRM, a ticketing system, or an internal API. Copilot Studio fits when the organization wants the experience and administration centered on Microsoft products. + +## Which platforms automate Outlook, Teams, SharePoint, and Excel with AI agents? + +Sim, Microsoft Copilot Studio with Power Automate, Zapier, Make, and n8n can participate in Microsoft 365 workflows. They differ in agent reasoning, model choice, deployment, administration, and billing. + +The table summarizes vendor-published product and pricing models as of November 2026. Check exact connector actions, licensing requirements, quotas, and enterprise controls for the operation and plan under consideration. + +| Platform | Microsoft 365 coverage | Agent and deployment approach | Pricing model as of November 2026 | Fit | +|---|---|---|---|---| +| **Sim** | [Outlook](https://docs.sim.ai/integrations/outlook), [Teams](https://docs.sim.ai/integrations/microsoft_teams), [SharePoint](https://docs.sim.ai/integrations/sharepoint), [OneDrive](https://docs.sim.ai/integrations/onedrive), [Excel](https://docs.sim.ai/integrations/microsoft_excel), and [Intune](https://docs.sim.ai/integrations/intune) | Visual multi-step agents with model choice; cloud or self-hosted deployment | Cloud plans and usage, or operator-managed infrastructure when self-hosted | Cross-system Microsoft 365 agents with flexible models and deployment | +| **Microsoft Copilot Studio and Power Automate** | [Power Platform connectors](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors) cover Microsoft and external services | Microsoft-managed agents, actions, environments, policies, and identity | [Copilot Credits or Microsoft 365 Copilot licensing](https://www.microsoft.com/en-us/copilot/pricing/copilot-studio), plus applicable [Power Automate licensing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing/) | Microsoft-first organizations standardizing on Power Platform | +| **Zapier** | Published integrations support [Outlook](https://help.zapier.com/hc/en-us/articles/18836255979277-How-to-get-started-with-Microsoft-Outlook-on-Zapier), [Teams](https://help.zapier.com/hc/en-us/articles/8496017011469-How-to-get-started-with-Microsoft-Teams-on-Zapier), [Excel](https://help.zapier.com/hc/en-us/articles/8496029352973-How-to-get-started-with-Microsoft-Excel-on-Zapier), OneDrive, and SharePoint | Hosted multi-step automation with plan-dependent administration | [Task-based subscription plans](https://zapier.com/pricing) | Hosted SaaS automation across Microsoft 365 and external apps | +| **Make** | Modules cover [Outlook](https://apps.make.com/microsoft-email), [Teams](https://www.make.com/en/integrations/microsoft-teams), [Excel](https://apps.make.com/microsoft-excel), [OneDrive, and SharePoint](https://www.make.com/en/integrations/microsoft-sharepoint/onedrive) | Hosted visual scenarios with routing and data transformation | [Credit-based subscription plans](https://www.make.com/en/pricing) | Detailed visual mapping and branching across apps | +| **n8n** | [Microsoft credentials](https://docs.n8n.io/integrations/builtin/credentials/microsoft) support Outlook, Teams, Excel, OneDrive, and SharePoint nodes | [n8n Cloud or self-hosting](https://docs.n8n.io/choose-how-to-use-n8n) with provider nodes, code, and branching | [Cloud plans based on workflow executions](https://n8n.io/pricing/), with operator infrastructure costs for self-hosting | Technical teams wanting execution-based billing and self-hosted automation | +| **Lindy** | [Microsoft Outlook actions](https://www.lindy.ai/integrations/microsoft-outlook) for email and contacts | Hosted AI assistance with plan-dependent team controls | [Per-user plans with pooled credits](https://www.lindy.ai/pricing) | Outlook-centered assistance | + +## What are the key facts about each Microsoft 365 agent platform? + +The six products can be distinguished by license, deployment, and billing unit. + +- **Sim:** Sim's core is Apache 2.0, while `apps/sim/ee` is covered by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Sim supports cloud and [self-hosted deployment](https://docs.sim.ai/platform/self-hosting), with cloud-plan and model-usage costs. +- **Microsoft Copilot Studio and Power Automate:** Microsoft's proprietary cloud services use Copilot capacity or credits and applicable Power Automate licensing, as described on the [Copilot Studio](https://www.microsoft.com/en-us/copilot/pricing/copilot-studio) and [Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing/) pricing pages. +- **Zapier:** Zapier is a proprietary hosted service with [task-based plans](https://zapier.com/pricing). +- **Make:** Make is a proprietary hosted service with [credit-based plans](https://www.make.com/en/pricing). +- **n8n:** n8n offers cloud and self-hosted deployment under its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source-available rather than OSI-approved open source. Its [cloud pricing](https://n8n.io/pricing/) is based on workflow executions. +- **Lindy:** Lindy is a proprietary hosted assistant with [per-user, credit-based plans](https://www.lindy.ai/pricing) and Outlook actions. + +## How do you build an AI agent that triages a connected Outlook inbox and drafts replies? + +Sim can triage the connected account's Outlook inbox by combining Outlook events and actions with classification, retrieval, reply drafting, approval, and escalation steps. + +A practical workflow is: + +1. Trigger the workflow when a new message reaches the connected account's inbox. +2. Extract the sender, subject, body, attachments, thread context, and mailbox metadata needed for the decision. +3. Classify the message by intent, urgency, account, and required team. +4. Retrieve approved context from SharePoint, OneDrive, a CRM, or another source. +5. Draft a reply grounded in the retrieved information. +6. Send the draft to a person for review when the reply is sensitive, uncertain, or consequential. +7. Route the approved response to Outlook, or escalate the thread without sending. +8. Record the decision, source material, approval, and final action for later review. + +In Sim, a Human in the Loop block pauses the run and resumes with submitted form fields. Approval is a field, so a downstream Condition must check the result before the Outlook send step executes. + +## How do you post approvals and summaries to a Microsoft Teams channel? + +Sim can post structured summaries to Microsoft Teams and route approval-dependent work through an explicit human decision before the workflow continues. + +A Teams approval workflow can: + +1. Receive an event from Outlook, a service desk, a CRM, or an internal system. +2. Summarize the request and identify missing or risky information. +3. Post the summary, source links, proposed action, and owner to the relevant Teams channel. +4. Pause the workflow for human input when an approval is required. +5. Use a Condition to route approved, rejected, and revision-requested outcomes. +6. Post the final status back to Teams and update the system of record. + +Teams should be the decision surface rather than the only system involved. The agent remains responsible for collecting context and coordinating approved actions across tools. + +## How do you build an AI SharePoint integration that answers questions from documents? + +Sim can answer questions from SharePoint documents by retrieving permitted source material, grounding the model's response in that material, and returning citations or document references with the answer. + +A reliable SharePoint question-answering workflow should: + +1. Accept the user's question from Teams, Chat, a form, or another approved interface. +2. Search only the SharePoint sites, libraries, or folders the workflow is authorized to use. +3. Retrieve the most relevant passages and document metadata. +4. Ask the model to answer from those passages rather than unsupported general knowledge. +5. Return the answer with source titles or links. +6. Decline or escalate when the available documents do not support an answer. +7. Log retrieval and response details needed for evaluation and troubleshooting. + +For a broader comparison, see [Best AI Agents for Internal Knowledge Search Across Docs and Tools](https://www.sim.ai/library/best-ai-agents-internal-knowledge-search). + +## How do you build a weekly Excel report from several systems with an AI agent? + +Sim can build a weekly Excel report by collecting records from several systems, normalizing fields, generating a controlled summary, and writing the result to an Excel workbook before notifying stakeholders. + +A weekly reporting workflow can: + +1. Run on a weekly schedule. +2. Fetch data from approved systems such as a CRM, support platform, database, or finance tool. +3. Normalize dates, names, currencies, identifiers, and missing values. +4. Calculate deterministic totals before asking a model to interpret trends. +5. Use the model to draft a narrative summary from the calculated figures. +6. Write rows, totals, and the summary to a designated Excel workbook. +7. Send the report link and key findings to Teams or Outlook. +8. Route anomalies or large changes to a person for review. + +AI should explain the report rather than invent its arithmetic. Totals and business rules should remain deterministic wherever possible. + +## What are the best autonomous IT support tools for Microsoft 365 in 2026? + +Sim, Microsoft Copilot Studio, and established IT service-management products address different autonomous Microsoft 365 support needs. Sim fits flexible cross-system agents, Copilot Studio fits Microsoft-native administration, and packaged service desks fit teams that prioritize an out-of-the-box ticket lifecycle. + +Sim fits when an IT agent must coordinate Outlook, Teams, SharePoint, Excel, Microsoft Intune, ticketing systems, internal APIs, and human approvals. Autonomous support should not mean unrestricted action. Password, device, access, deletion, or policy changes should use least-privilege credentials, explicit conditions, approval where appropriate, and an auditable record of inputs and outcomes. + +For service-desk product comparisons, see [Best AI Agents for IT Helpdesk Teams: 6 Platforms Compared](https://www.sim.ai/library/best-ai-agents-for-it-helpdesk-teams). + +## How are enterprise agent platforms typically priced for Outlook automation across 500 users with auditability requirements? + +Sim, Microsoft Copilot Studio, Zapier, Make, n8n, and Lindy combine subscriptions, usage units, model costs, infrastructure costs, and enterprise-control fees differently. A 500-user Outlook deployment should therefore be modeled from expected runs rather than seats alone. + +As of November 2026, buyers should calculate: + +- Platform subscription or capacity charges. +- The number of messages, workflow runs, tasks, executions, or credits consumed. +- Model input and output usage. +- Premium Microsoft connectors or related Microsoft licensing. +- Enterprise identity, audit, retention, and access-control requirements. +- Self-hosted compute, storage, monitoring, upgrades, and support. +- Development, evaluation, and exception-handling costs. + +Request a workload-based estimate using real message volume. A useful pilot records messages per user, percentage requiring AI, average model tokens, tool calls per run, approval rate, retries, and retention volume. This estimate is more reliable than multiplying a public entry price by 500. + +Teams using Sim Cloud can use workspace BYOK on any plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise. The [Sim cost documentation](https://docs.sim.ai/platform/costs) explains how provider billing changes when teams use their own keys. [Self-hosted Sim deployments](https://docs.sim.ai/platform/self-hosting) can connect local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise solely for local-model support. + +## What administration and security controls should a Microsoft 365 AI agent have? + +A Microsoft 365 AI agent should use least-privilege credentials, explicit action boundaries, human approval for consequential work, and logs that connect each decision to its inputs and tool calls. + +Evaluate each platform for: + +- Microsoft Entra ID and single sign-on requirements. +- Separation of development, test, and production environments. +- Per-workflow credentials and permission scopes. +- Credential rotation and ownership. +- Role-based access and workspace boundaries. +- Audit logs and execution history. +- Data retention and deletion controls. +- Model-provider and data-residency requirements. +- Human review before sending messages or changing records. +- Failure handling, retries, idempotency, and rollback procedures. +- Evaluation against representative Outlook, Teams, SharePoint, and Excel cases. + +Sim's [enterprise capabilities](https://docs.sim.ai/platform/enterprise) include identity, access, audit, retention, and administration controls. Those capabilities live in `apps/sim/ee` under the Sim Enterprise License, and production use requires an active Sim Enterprise subscription. + +## When should you choose Microsoft Copilot Studio instead of Sim? + +Microsoft Copilot Studio fits when Microsoft-native administration, Power Platform skills, and alignment with an existing Microsoft architecture matter more than open-source core licensing, self-hosting flexibility, or broad model choice. + +Sim fits when the agent must span Microsoft and non-Microsoft systems, use different model providers, run in a self-hosted environment, or give technical and semi-technical teams one visual workspace for building and managing agents. + +Teams evaluating replacements for Microsoft's builder can read [6 Best Microsoft Copilot Studio and Power Automate Alternatives for AI Agents in 2026](https://www.sim.ai/library/best-microsoft-copilot-studio-power-automate-alternatives). + +## Which Microsoft 365 AI agent platform should you choose? + +Choose **Sim** for multi-step agents, model choice, cloud or self-hosted deployment, and work that crosses Microsoft 365 and other systems. Choose **Microsoft Copilot Studio and Power Automate** for Microsoft-native administration and Power Platform standardization. Choose **Zapier** for hosted trigger-and-action automation, **Make** for visual data mapping, **n8n** for technical self-hosting and execution-based workflows, or **Lindy** for Outlook-centered assistance. + +The final decision should be based on a pilot using representative mailbox volume, SharePoint permissions, Excel data, Teams interactions, failure cases, approvals, and audit requirements. diff --git a/apps/sim/content/library/salesforce-mcp-server-ai-agent-sim-setup/index.mdx b/apps/sim/content/library/salesforce-mcp-server-ai-agent-sim-setup/index.mdx new file mode 100644 index 00000000000..14cd9a3ff18 --- /dev/null +++ b/apps/sim/content/library/salesforce-mcp-server-ai-agent-sim-setup/index.mdx @@ -0,0 +1,320 @@ +--- +slug: salesforce-mcp-server-ai-agent-sim-setup +title: 'How Do You Connect Salesforce’s Hosted MCP Server to an AI Agent in Sim?' +seoTitle: 'Salesforce MCP Server Setup for AI Agents' +description: 'Connect Salesforce''s hosted MCP server to an AI agent in Sim with least-privilege access, read-only testing, and approval gates for CRM writes.' +date: 2026-11-05 +updated: 2026-11-05 +authors: + - andrew +readingTime: 14 +tags: [Salesforce, MCP, AI Agents, CRM, Sim] +ogImage: /library/salesforce-mcp-server-ai-agent-sim-setup/cover.jpg +draft: false +faq: + - q: "What is the Salesforce MCP server?" + a: "Salesforce’s hosted MCP server is a Salesforce-operated remote server that exposes approved Salesforce capabilities as MCP tools for compatible clients." + - q: "Does Sim provide the Salesforce MCP server?" + a: "Sim does not provide or host Salesforce’s MCP server; Salesforce provides the server, and Sim connects to it as an MCP client." + - q: "How do you connect Salesforce’s hosted MCP server to Sim?" + a: "Sim connects to Salesforce’s hosted MCP server by using the Salesforce-provided remote endpoint, selecting the matching transport, completing the required authentication, and enabling only the necessary tools." + - q: "Can you connect the Salesforce MCP server to an AI agent in Sim?" + a: "Salesforce’s hosted MCP server can connect to an AI agent in Sim when its remote transport and authentication requirements are supported by Sim’s current MCP client." + - q: "Does Salesforce MCP replace Sim’s native Salesforce integration?" + a: "Salesforce MCP does not replace Sim’s native Salesforce integration because native actions remain the simpler option for many known Salesforce operations." + - q: "Should you use Sim’s native Salesforce integration or Salesforce MCP?" + a: "Sim’s native Salesforce integration fits supported, predictable actions, while Salesforce MCP is useful when a compatible Salesforce-hosted server exposes a required tool through MCP." + - q: "Can Sim use Salesforce MCP and the native Salesforce integration in the same workflow?" + a: "Sim can use Salesforce MCP and native Salesforce integration actions in the same workflow when each connection serves a distinct, permission-scoped purpose." + - q: "What permissions does a Salesforce MCP agent need?" + a: "A Salesforce MCP agent needs only the server, object, field, record, and operation permissions required for its specific task." + - q: "Should a Salesforce MCP agent use an administrator account?" + a: "A Salesforce MCP agent should not use a general administrator account because a dedicated least-privilege integration identity limits the impact of mistakes or credential misuse." + - q: "How do you test Salesforce MCP safely?" + a: "Salesforce MCP should first be tested in a sandbox with read-only tools, a known non-sensitive record, a small field set, and a strict result limit." + - q: "How do you prevent a Salesforce MCP agent from changing CRM records?" + a: "Salesforce MCP writes can be prevented by withholding mutation permissions, disabling write tools, and keeping the first workflow read-only." + - q: "How do you approve Salesforce MCP updates before they run?" + a: "Sim can pause a proposed Salesforce MCP update with Human in the Loop and use a downstream Condition to invoke the write tool only when the reviewer approves it." + - q: "Does Human in the Loop automatically block rejected Salesforce updates?" + a: "Sim’s Human in the Loop block does not automatically enforce rejection because a downstream Condition must evaluate the submitted decision and route rejected runs away from the write tool." + - q: "Why is the Salesforce MCP server returning an authentication error?" + a: "Salesforce MCP authentication errors usually result from an incorrect endpoint, environment mismatch, redirect URI mismatch, revoked authorization, or insufficient Salesforce permissions." + - q: "Why can Sim connect to Salesforce MCP but not see any tools?" + a: "Sim may connect without seeing Salesforce MCP tools when the URL is not the MCP endpoint, the transport is mismatched, authorization is incomplete, or Salesforce has not exposed tools to that identity." + - q: "Why does Salesforce MCP return no CRM records?" + a: "Salesforce MCP may return no CRM records when the authenticated identity cannot see the records or fields, or when the requested filters do not match the data." + - q: "Can Salesforce MCP bypass Salesforce permissions?" + a: "Salesforce MCP cannot bypass the object, field, record, sharing, and operation permissions enforced for the authenticated Salesforce identity." + - q: "Is Salesforce MCP the same as the Salesforce API?" + a: "Salesforce MCP is not the same interface as a direct Salesforce API integration because MCP presents server-defined tools that a compatible AI client can discover and invoke." + - q: "Is Salesforce MCP better than a native Salesforce integration?" + a: "Salesforce MCP fits cases where a compatible hosted server exposes the exact tools an MCP-centered architecture needs, while a native Salesforce integration fits straightforward supported actions." + - q: "Can n8n connect AI agents to Salesforce MCP?" + a: "n8n can be evaluated as an alternative MCP client, but buyers must confirm its current transport, authentication, tool-control, and approval capabilities against the selected Salesforce endpoint." + - q: "What is the safest first Salesforce MCP query?" + a: "Salesforce MCP is safest to test with a bounded read of one known record or a maximum of five records using only non-sensitive fields." + - q: "Should Salesforce MCP credentials be placed in an AI prompt?" + a: "Salesforce MCP credentials should never be placed in an AI prompt because prompts and model-visible workflow fields are not appropriate credential storage locations." +--- + +## TL;DR + +Salesforce’s hosted MCP server can expose CRM tools to an agent in Sim, while Sim acts as the MCP client rather than providing or hosting the Salesforce server. Integration teams should verify Salesforce’s endpoint and OAuth requirements against [Salesforce’s hosted MCP documentation](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/hosted-mcp-servers-overview.html) and Sim’s current [external MCP server documentation](https://docs.sim.ai/agents/mcp). + +Start with a dedicated, least-privilege Salesforce identity and a read-only server or tool set. Before any write, place Human in the Loop in the workflow and route its approve-or-reject field through a downstream Condition. Sim’s native Salesforce integration remains the simpler choice when its prebuilt actions already cover the task. + +> **Key distinction:** Salesforce hosts and defines the MCP server and its tools; Sim connects to that remote server as an MCP client; Sim’s native Salesforce integration connects through prebuilt Salesforce actions without requiring MCP. + +## What is the Salesforce MCP server? + +[Salesforce’s hosted MCP server](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/hosted-mcp-servers-overview.html) is a Salesforce-operated remote server that exposes approved Salesforce capabilities as Model Context Protocol tools. + +MCP gives an AI agent a standardized way to discover and invoke tools. In this setup, Salesforce controls the server endpoint, authentication requirements, available tools, and underlying access to CRM data. Sim consumes those tools inside an agent workflow. + +As of October 2026, integration teams should use the Salesforce hosted MCP server documentation as the source of truth for the current server URL, supported transport, authentication flow, and available tools. + +A Salesforce-hosted MCP connection is not the same as: + +- Sim hosting a Salesforce MCP server +- Sim’s native Salesforce integration +- An agent receiving unrestricted access to every Salesforce record +- A locally installed stdio MCP server + +## Can you connect the Salesforce MCP server to an AI agent in Sim? + +Salesforce’s hosted MCP server can connect to Sim when the selected Salesforce endpoint uses the remote Streamable HTTP transport and its OAuth setup is compatible with Sim. Sim supports remote Streamable HTTP registrations, not local stdio commands, according to the [Sim MCP client documentation](https://docs.sim.ai/agents/mcp). + +As of October 2026, confirm both sides of the connection before entering production credentials: + +1. Salesforce documents the endpoint as a remote MCP server rather than a local stdio server. +2. The server uses Streamable HTTP, the remote transport accepted by Sim. +3. Sim can complete the OAuth method required by that Salesforce endpoint. +4. The Salesforce identity has permission to use the requested CRM objects and operations. +5. The server exposes the specific read or write tools the workflow needs. + +Do not substitute a local command, package name, or stdio configuration for Salesforce’s hosted URL. + +## What should you verify before connecting Salesforce MCP to Sim? + +Salesforce and Sim require a compatible endpoint, transport, authentication flow, and permission model before the first tool call can succeed. Salesforce’s [client connection guide](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/client-connection-overview.html) requires an External Client App for OAuth registration, while Sim lets a workspace owner register a remote server and provide OAuth client details when automatic registration is unavailable. + +Collect these items before configuring the connection: + +- The exact hosted MCP server URL from Salesforce +- The Streamable HTTP transport used by Sim +- The Salesforce environment, such as production or a sandbox +- The External Client App and OAuth requirements documented by Salesforce +- The Salesforce user who will authorize access +- The minimum object, field, and operation permissions required +- The list of MCP tools the agent should be allowed to use +- A non-sensitive Account, Contact, Lead, or Opportunity record for the first read test + +Use a Salesforce sandbox for initial testing when one is available. Salesforce publishes [separate hosted MCP URL formats for production and sandbox organizations](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/connection-issues.html), so confirm that the URL and authorizing identity belong to the same environment. + +## How do you connect Salesforce’s hosted MCP server to Sim? + +Sim connects to Salesforce’s hosted MCP server by registering the remote endpoint in its MCP client configuration, supplying the Salesforce External Client App details, authenticating the Salesforce identity, and selecting only the tools the agent needs. + +### Which Salesforce identity should you use for an MCP agent? + +Salesforce should be authorized with a dedicated, least-privilege user rather than a sales administrator’s everyday account. Salesforce states that each MCP tool call inherits the authorizing user’s object access, field-level security, and record sharing rules in its [hosted MCP security guidance](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/security-best-practices.html). + +Give that identity access only to the required objects, fields, records, and operations. For a read-only proof of concept, use Salesforce’s read-only SObject server or omit create, update, delete, merge, and other mutation permissions wherever the Salesforce permission model allows it. + +Also confirm whether Salesforce sharing rules, permission sets, field-level security, and record ownership narrow what the identity can retrieve. An MCP server does not override the Salesforce permissions of the authenticated identity. + +### Where do you get the Salesforce MCP server URL? + +Salesforce provides the hosted MCP server URL in its first-party hosted-server documentation or the applicable Salesforce setup surface. Its [connection troubleshooting guide](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/connection-issues.html) lists different URL formats for production, sandbox, standard, and custom servers. + +Copy the exact URL for the intended Salesforce environment. Do not infer the URL from the organization’s normal Lightning domain, and do not copy an endpoint from a third-party tutorial without comparing it with the current Salesforce documentation. + +### Which transport should you choose for the Salesforce MCP server? + +Configure the Salesforce hosted endpoint as Streamable HTTP in Sim. Salesforce’s client examples connect to its hosted servers over HTTP, and [Sim registers external MCP servers through Streamable HTTP](https://docs.sim.ai/agents/mcp). + +A hosted remote endpoint should not be configured as a local stdio process. Confirm that the current Salesforce documentation still identifies the URL as its hosted HTTP endpoint before authorizing it. + +### How do you authenticate Salesforce MCP in Sim? + +Salesforce hosted MCP uses OAuth 2.0, and its setup requires an [External Client App](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/create-external-client-app.html). In Sim, open **Settings → MCP tools**, add the Salesforce server URL, and use Advanced settings to enter the External Client App’s client ID and secret when automatic registration is unavailable. Save the server, but register the exact Sim deployment callback URL in Salesforce before selecting **Authorize**: `https:///api/mcp/oauth/callback`. Then complete browser sign-in with the intended Salesforce user. + +The callback URL must use the same Sim deployment origin as the connection. The client configuration, Salesforce environment, and authorizing identity must also belong to that intended connection. If authorization fails, compare the callback exactly with the value registered in Salesforce because its [authentication troubleshooting guide](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/authentication-issues.html) identifies redirect mismatch as a common failure point. + +Do not paste a long-lived Salesforce access token into an agent prompt or ordinary workflow field. Credentials should remain in the platform’s credential handling path rather than becoming model-visible context. + +### Which Salesforce MCP tools should you enable first? + +Salesforce MCP should initially expose only the smallest set of read tools needed for the test scenario. Salesforce offers separate standard SObject servers for read, mutation, delete, and broader CRUD access in its [standard server reference](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/servers-reference.html). + +Start with tools that retrieve, search, describe, or query CRM data. Leave create, update, delete, send, merge, convert, and other state-changing tools unavailable until the read path works and the approval design has been tested. + +A sensible first allowlist might cover: + +- Reading an Account by ID +- Searching for Accounts by name +- Retrieving a small set of non-sensitive Account fields +- Describing the fields available to the authenticated identity + +Actual tool names are defined by the Salesforce-hosted server and may differ by server. Select tools by documented behavior rather than assuming a name. + +### How do you run a read-only Salesforce MCP test in Sim? + +Sim should make the first Salesforce MCP call with a bounded request that retrieves a few non-sensitive records and makes no changes. + +After authorizing the server, add an **Agent** block and choose a model that supports tool calling. Open the Agent block’s **Tools** input, select **Add tool...**, open **MCP Servers**, choose the Salesforce server, and add only the read tools needed for this test. Authorizing a server makes the connection available to the workspace, but the Agent can call only the tools added to its **Tools** input. + +Use a prompt such as: + +> Find up to five Salesforce Accounts whose names contain “Acme.” Return only the Account ID, Name, and Last Modified Date. Do not create, update, delete, merge, convert, or send anything. + +Inspect the selected tool, arguments, returned fields, record count, and errors. A successful test should demonstrate that: + +- Sim discovered an appropriate read tool. +- Salesforce accepted the authenticated identity. +- The identity could access the requested object and fields. +- The tool returned no more records than requested. +- No write operation was selected or executed. + +If the server exposes a general query tool, constrain the object, fields, filters, and result limit. Do not begin with an open-ended request to retrieve all customer data. + +## How do you require approval before Salesforce MCP writes? + +Sim can place Human in the Loop before a Salesforce MCP write, but a downstream Condition must evaluate the reviewer’s approve-or-reject response before the write tool can run. + +A safe write path is: + +1. Sim reads the relevant Salesforce record. +2. Sim drafts the proposed field changes. +3. Sim presents the record ID, current values, proposed values, and reason to Human in the Loop. +4. A reviewer submits an approve-or-reject field. +5. A downstream Condition checks that field. +6. Only the approved branch invokes the Salesforce MCP write tool. +7. The rejected branch ends without changing Salesforce. +8. Sim reads the record again and records the observed result. + +Human in the Loop pauses the run and resumes with submitted form fields; it does not independently enforce the decision. The downstream Condition is what prevents the rejected path from reaching the write operation. + +For higher-risk changes, require approval for each record or small batch rather than approving an unbounded update. Avoid allowing the model to choose its own approval criteria. For more detail, see [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents). + +## What should a safe Salesforce MCP workflow look like? + +Sim should separate Salesforce retrieval, model reasoning, human approval, and CRM mutation into visible stages. + +A practical workflow is: + +`User request → Salesforce MCP read tool → structured proposed change → Human in the Loop → Condition → Salesforce MCP write tool → Salesforce MCP verification read → result` + +Keep the read and write tools separate when the server permits tool-level selection. Give the model a structured schema for proposed changes, including the Salesforce record ID, field name, current value, proposed value, and reason. + +The final verification read should confirm the stored Salesforce value instead of treating a model-generated statement as proof that the update succeeded. + +## Should you use Sim’s native Salesforce integration or Salesforce MCP? + +Sim’s [native Salesforce integration](https://docs.sim.ai/integrations/salesforce) is usually the simpler choice for known Salesforce actions, while Salesforce-hosted MCP is useful when a compatible server exposes the required tools through a standardized MCP interface. + +| Buyer question | Sim native Salesforce integration | Salesforce-hosted MCP consumed by Sim | +|---|---|---| +| Who provides the interface? | Sim provides prebuilt Salesforce integration actions. | Salesforce provides the hosted MCP server and tool definitions; Sim provides the MCP client. | +| What does it fit? | A known workflow with supported Salesforce actions and predictable field mapping. | An MCP-centered architecture or a Salesforce-hosted tool that is not represented by the required native action. | +| How are capabilities selected? | The builder selects a specific native Salesforce action. | The builder selects or permits tools exposed by the remote server. | +| Where does authentication terminate? | Authentication follows the native Salesforce integration flow. | Authentication follows the Salesforce hosted-server requirements through the MCP connection. | +| How should writes be controlled? | Put Human in the Loop and a Condition before a native write action. | Allowlist write tools, then put Human in the Loop and a Condition before invoking one. | +| What is easier to troubleshoot? | Native actions reduce the connection layers when the required action already exists. | MCP requires checking endpoint, transport, OAuth, tool discovery, and Salesforce permissions. | +| Can both be used in one workflow? | Yes, when native actions are the clearest option for some steps. | Yes, when MCP supplies a distinct tool needed by another step. | + +Do not choose MCP solely because it is a newer protocol. Choose it when Salesforce’s server exposes a useful capability and the additional endpoint, transport, authentication, and tool-governance layers are justified. + +## How is this different from building a Salesforce lead-scoring agent? + +This Salesforce MCP guide explains the connection and permission layer, while Sim’s lead-scoring guide explains a specific sales workflow. + +Use [How Do You Build a Salesforce AI Lead-Scoring Agent With Sim?](https://www.sim.ai/library/salesforce-ai-agent-lead-scoring-workflow) when the goal is to score and route leads. Use this guide when the buyer first needs to determine whether Salesforce’s hosted MCP server can securely supply CRM tools to Sim. + +For broader context, see [What Is an MCP Server?](https://www.sim.ai/library/what-is-an-mcp-server), [Best AI Agent Builders with MCP Support](https://www.sim.ai/library/best-ai-agent-builders-with-mcp-support), and [MCP Security: A Practical Guide to Secure MCP Server Development](https://www.sim.ai/library/mcp-security). + +## How does Sim compare with n8n for Salesforce MCP? + +Sim and n8n can both be evaluated as MCP clients for agent workflows. n8n documents an [MCP Client Tool node for agents](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) and a [native Salesforce node](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.salesforce). Buyers should compare each product’s current remote-transport support, OAuth handling, tool controls, approval design, and execution visibility against the selected Salesforce endpoint. + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents, including workflows that consume remote MCP tools. Sim fits teams that want a visual agent workflow with explicit tool selection, approval steps, and execution logs. + +For this use case, evaluate both products with the same test: + +1. Connect the same Salesforce sandbox identity. +2. Permit the same read-only tools. +3. Run the same bounded Account lookup. +4. Inspect the selected tool and arguments. +5. Add an approval gate before the same controlled write. +6. Confirm the result with a second Salesforce read. + +The right fit depends on whether the team wants an AI workspace for building and managing agents in Sim or already operates its automations in n8n. Neither client changes the permissions enforced by Salesforce. + +## Why is the Salesforce MCP server returning an authentication error? + +Salesforce MCP authentication errors usually come from a mismatched environment, External Client App configuration, redirect URI, expired authorization, or insufficient Salesforce access. Salesforce’s [authentication troubleshooting steps](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/authentication-issues.html) recommend checking the registered app, callback, and client OAuth compatibility. + +| Symptom | Likely cause | What to check | +|---|---|---| +| Authorization page does not open | The endpoint or authentication mode is incorrect. | Compare the URL and OAuth method with Salesforce’s current hosted-server documentation. | +| Redirect URI mismatch | The callback registered in Salesforce differs from the one used by Sim. | Copy the exact callback value generated by Sim. | +| Login succeeds but tools fail | The identity lacks server, object, field, or record access. | Review permission sets, field-level security, sharing rules, and server eligibility. | +| Production works but sandbox fails | The connection points to the wrong Salesforce environment. | Confirm the sandbox endpoint and sandbox-authorized identity. | +| Connection worked and later stopped | Authorization may have expired or been revoked. | Reauthorize the credential and inspect Salesforce access policies. | + +Do not solve an authentication failure by granting broad administrator access. Identify the missing permission and add only what the intended tool requires. + +## Why can Sim connect to Salesforce MCP but not see any tools? + +Sim may establish a network connection without discovering usable Salesforce tools when authorization, server configuration, or tool exposure is incomplete. Sim’s MCP documentation notes that a [successful connection can expose no tools](https://docs.sim.ai/agents/mcp). + +Check these items in order: + +1. Confirm that the URL is an MCP endpoint rather than a normal Salesforce REST or Lightning URL. +2. Confirm that the endpoint uses Streamable HTTP. +3. Reauthorize the intended Salesforce identity. +4. Verify that Salesforce exposes tools for that server and identity. +5. Refresh tool discovery in the Sim MCP configuration. +6. Check whether an allowlist or server policy hides every tool. +7. Test one documented read tool before adding model-driven selection. + +A successful connection is not proof that the authenticated identity can invoke every advertised tool. + +## Why does a Salesforce MCP query return no records? + +Salesforce MCP can return no records when the authenticated identity lacks record visibility, the query uses inaccessible fields, or the filter does not match the intended data. Salesforce confirms that hosted MCP calls respect [field-level security, object permissions, and sharing rules](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/servers-reference.html). + +Test with a record that the integration identity can open directly in Salesforce. Then reduce the request to a known object, a known record ID, and a small set of permitted fields. If that works, add filters and additional fields one at a time. + +An empty result should not automatically trigger a broader query. Expanding scope can expose unrelated customer data without fixing the underlying permission or filter problem. + +## How do you secure Salesforce CRM data used through MCP? + +Salesforce CRM data used through MCP should be protected with least-privilege identities, narrow tool access, bounded queries, human approval for writes, and logs that show which tool ran with which arguments. + +Apply these controls: + +- Use a dedicated Salesforce integration user. +- Start in a sandbox and with read-only permissions. +- Restrict object, field, record, and operation access in Salesforce. +- Permit only the MCP tools required by the workflow. +- Keep credentials out of prompts and model-visible fields. +- Limit record counts and returned fields. +- Require Human in the Loop plus a downstream Condition before writes. +- Verify successful writes with a follow-up read. +- Remove credentials and server access when the workflow is retired. +- Review Salesforce and Sim execution records during incident investigation. + +For general server-side threat modeling, use [MCP Security: A Practical Guide to Secure MCP Server Development](https://www.sim.ai/library/mcp-security). + +## Which first-party documentation should integration buyers use? + +Salesforce and Sim first-party documentation should be the source of truth for endpoint, transport, authentication, and client configuration details. + +As of October 2026, use: + +- [Salesforce hosted MCP server overview](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/hosted-mcp-servers-overview.html) +- [Sim external MCP server documentation](https://docs.sim.ai/agents/mcp) + +Third-party walkthroughs can provide examples, but they should not override the current Salesforce endpoint, OAuth, permission, or transport requirements. diff --git a/apps/sim/content/library/what-is-human-in-the-loop-in-ai-agents/index.mdx b/apps/sim/content/library/what-is-human-in-the-loop-in-ai-agents/index.mdx index 37e9631a036..7f660111fa4 100644 --- a/apps/sim/content/library/what-is-human-in-the-loop-in-ai-agents/index.mdx +++ b/apps/sim/content/library/what-is-human-in-the-loop-in-ai-agents/index.mdx @@ -3,22 +3,24 @@ slug: what-is-human-in-the-loop-in-ai-agents title: 'What Is Human-in-the-Loop in AI Agents?' description: 'Learn how human-in-the-loop AI agents use approval gates, in-line review, exception escalation, and post-hoc audits to control consequential actions.' date: 2026-09-02 -updated: 2026-10-08 +updated: 2026-11-05 authors: - andrew -readingTime: 16 +readingTime: 19 tags: [AI Agents, Human in the Loop, Workflow Automation, Sim] ogImage: /library/what-is-human-in-the-loop-in-ai-agents/cover.jpg draft: false faq: - q: "How does agentic HITL differ from HITL in model training?" - a: "Agentic HITL governs runtime decisions, while training HITL uses human feedback during model development or evaluation. Sim places human review within workflow execution rather than model training. Runtime review can stop a questionable action before it affects another system." + a: "Agentic HITL governs a specific runtime decision and can stop a questionable action before it affects another system, while training HITL uses human feedback during model development or evaluation. Sim places human review within workflow execution rather than model training." - q: "Does HITL slow down AI agents?" - a: "Human review adds waiting time wherever a workflow pauses for a decision. In Sim, you can reserve the Human in the Loop block for actions that require judgment. Selective checkpoints preserve speed for low-risk work while protecting sensitive actions." + a: "Human review adds waiting time wherever a workflow pauses for a decision. In Sim, selective Human in the Loop checkpoints can preserve speed for low-risk work while reserving review for actions that require judgment." - q: "How should approval timeouts and escalations work?" - a: "In Sim, a Human in the Loop block has no default timeout. It pauses indefinitely until someone responds through the approval portal, the API, or a webhook. If a workflow needs a hard deadline, pair the block with a separate timeout mechanism and route expired requests to the safer outcome, such as rejecting the action or escalating to another reviewer." + a: "In Sim, a Human in the Loop block has no default timeout and pauses until someone responds through the approval portal, the API, or a webhook. If a workflow needs a hard deadline, pair the block with a separate timeout mechanism and route expired requests to a safer outcome, such as rejecting the action or escalating to another reviewer." - q: "Does every agent action need a human checkpoint?" - a: "A checkpoint controls a specific action, so an agent does not need one for every step. In Sim, you can place human review at selected points in a workflow rather than applying it to every action. Read-only retrieval and low-impact drafting may run autonomously, while irreversible or regulated actions should receive review." + a: "A checkpoint controls a specific action, so an agent does not need one for every step; excessive review creates delay and encourages rubber-stamping. Read-only retrieval and low-impact drafting may run autonomously, while irreversible or regulated actions should receive review at selected workflow points." + - q: "Should every AI agent action require human approval?" + a: "AI agent actions should not all require human approval because excessive review creates delay and encourages rubber-stamping; approval should focus on risk-bearing decisions." - q: "What is human-in-the-loop in AI?" a: "Human-in-the-loop in AI is a design pattern in which a person reviews, corrects, approves, rejects, or supplements an AI system’s work at a defined point in the process." - q: "What is human-in-the-loop in an AI agent?" @@ -31,8 +33,6 @@ faq: a: "Human-in-the-loop requires a person to participate before or during a defined action, while human-on-the-loop generally means a person supervises an autonomous system and can intervene when necessary." - q: "When should an AI agent ask for human approval?" a: "An AI agent should ask for human approval before high-impact, irreversible, unusually expensive, low-confidence, policy-exception, or legally sensitive actions." - - q: "Should every AI agent action require human approval?" - a: "AI agent actions should not all require human approval because excessive review creates delay and encourages rubber-stamping; approval should focus on risk-bearing decisions." - q: "What happens when a human rejects an AI agent’s action?" a: "A human rejection should route the AI agent to a predefined safe path such as stopping the run, requesting revision, escalating the case, or returning it to a queue." - q: "What happens if nobody responds to a human approval request?" @@ -52,14 +52,26 @@ faq: - q: "How do you audit human approvals in an AI workflow?" a: "A human-in-the-loop audit record should capture the request, relevant inputs, reviewer identity, submitted decision, timestamp, comments, and resulting workflow action in the appropriate system of record." - q: "Is human-in-the-loop enough to make an AI agent safe?" - a: "Human-in-the-loop is not enough to make an AI agent safe because reliable operation also requires access controls, testing, observability, evaluations, secure integrations, and defined failure handling." + a: "Human-in-the-loop is not enough to make an AI agent safe because reliable operation also requires access controls, testing, observability, evaluations, secure integrations, and defined failure handling. It reduces risk by keeping a person in control of selected consequential actions and preventing uncertain or exceptional cases from proceeding automatically." - q: "What is the best AI agent builder for human approval workflows?" - a: "Sim is a strong choice for human approval workflows when a team needs a visual workflow that pauses for structured human input and then routes the result through an explicit Condition." + a: "Sim fits teams that need a visual workflow that pauses for structured human input and then routes the result through an explicit Condition; the Best AI Agent Builders for Human Approval Workflows guide compares additional options." - q: "Does n8n support human-in-the-loop approval?" - a: "n8n supports human review for selected AI Agent tool calls according to n8n’s first-party documentation as of October 2026." + a: "n8n supports human review for selected AI Agent tool calls according to n8n’s first-party documentation as of November 2026." + - q: "What is the difference between human approval and human escalation?" + a: "Human approval asks a person to accept or reject a defined proposal, while human escalation transfers an unresolved case to someone with the judgment or authority to handle it." + - q: "Does Sim’s Human in the Loop block notify the reviewer?" + a: "The Human in the Loop block can use its configured notification tools to alert a reviewer with the generated approval URL before collecting the reviewer’s response." + - q: "Is a notification the same as human-in-the-loop approval?" + a: "A notification is not human-in-the-loop approval unless the workflow pauses, collects an authorized decision, and routes execution based on that decision before the action occurs." + - q: "Is human-in-the-loop the same as AI guardrails?" + a: "Human-in-the-loop is not the same as AI guardrails because guardrails perform automated checks while human-in-the-loop sends selected decisions to a person." + - q: "Can human-in-the-loop be used for CRM updates?" + a: "Sim can place a Human in the Loop block before a CRM write so a reviewer can inspect proposed field changes and a downstream Condition can permit or reject the update." + - q: "Can human-in-the-loop be used for outbound emails?" + a: "Sim can pause an outbound-email workflow after drafting and before sending so a reviewer can inspect the recipient, subject, and body before a Condition routes the email to send or revision." --- -Human-in-the-loop (HITL) in AI agents is a control pattern that pauses or redirects an agent so a person can review context, provide input, approve an action, or reject it before the workflow continues. +Human-in-the-loop in AI agents is a control pattern in which an AI agent pauses at a defined decision point so a person can review information, supply input, or approve an action before execution continues. Human-in-the-loop control makes agents safer for consequential work because people retain authority over selected decisions while the agent handles the surrounding research, drafting, and system operations. ## TL;DR @@ -126,6 +138,16 @@ Human-in-the-loop approval design should support pre-action approval, escalation These patterns can be combined. For example, a workflow can pause for pre-action approval, route a rejection to revision, escalate after a timeout, and write the final decision to a system of record. +Production review triggers usually fall into three practical patterns: + +| Human-in-the-loop pattern | When teams should use it | Typical risk level | Example | +|---|---|---:|---| +| Approval before an action | An agent is ready to communicate externally, move money, change access, or modify a system of record | High | Approve an outbound email before sending it or approve a CRM stage change before writing it | +| Review of low-confidence output | Confidence, evidence quality, or validation results fall below a defined threshold | Medium to high | Route an uncertain support-ticket classification to an operations specialist | +| Escalation to a person | The agent encounters an exception, policy conflict, sensitive request, or decision outside its authority | Variable | Escalate a refund exception or procurement request to an authorized employee | + +Confidence should not rely only on an LLM's self-reported score. Missing sources, conflicting evidence, structured-output validation failures, and unusual values can also trigger review. In Sim, a Guardrails block reports whether its checks passed or failed; a downstream Condition must route the run based on that result before a Human in the Loop block pauses the review path. + ## Four human-review patterns for agent workflows Four common HITL patterns place human judgment at different points in an agent's run. Earlier checkpoints can prevent an agent from completing a harmful action, but they add delay and reviewer workload. Selective or post-hoc review requires less immediate attention, although reviewers may not see an error until after it affects another system. @@ -205,7 +227,7 @@ The request should usually include: - The agent’s recommendation and uncertainty - The expected impact and available rollback - The deadline and timeout behavior -- Clear approve, reject, revise, and escalate choices +- Clear approve, reject, revise, and escalate choices, including the effect of each decision - Any required comment or justification field The interface should ask for structured decisions wherever downstream routing depends on the answer. Free-text comments can provide useful context, but a condition should not have to infer whether “looks fine to me” means formal approval. @@ -226,7 +248,7 @@ A supervised rollout provides evidence for setting those thresholds. Start by re Sim exposes a dedicated [Human in the Loop block](https://docs.sim.ai/workflows/blocks/human-in-the-loop) within its workflow builder. Sim lists the Human in the Loop block alongside Guardrails and Evaluator blocks as separate core blocks, and a separate Wait block exists too, so a human review checkpoint is a distinct primitive from a generic delay. -The block pauses a run and waits for a person before continuing. Configuration covers what the approver sees, drawn from earlier block outputs, how they get notified through Slack, Gmail, Microsoft Teams, SMS, or a custom webhook, and a resume form that captures their decision. By default the run stays paused with no timeout until someone responds through the approval portal, the API, or a webhook, and downstream blocks can read the approver's inputs directly. Workflows can chain multiple Human in the Loop blocks for staged approvals, such as a manager sign-off followed by a director sign-off. +The block pauses a run and waits for a person before continuing. Configuration covers what the approver sees, drawn from earlier block outputs, and a resume form that captures their decision. The block's Notification (Send URL) setting can configure Slack, Gmail, Microsoft Teams, SMS, or a custom webhook to alert the reviewer with the generated approval URL. By default the run stays paused with no timeout until someone responds through the approval portal, the API, or a webhook, and downstream blocks can read the approver's inputs directly. Workflows can chain multiple Human in the Loop blocks for staged approvals, such as a manager sign-off followed by a director sign-off. Sim's logs record every run block by block, so a reviewer can inspect the path a workflow took, see what each block produced, and confirm whether a review step ran. That record supports the post-hoc audit pattern even on workflows that never pause for approval, and it helps identify where future runs should add a checkpoint. For a broader treatment of logs and traces, see this guide to [AI agent observability](https://www.sim.ai/library/ai-agent-observability). @@ -242,11 +264,14 @@ A safe Sim pattern is: 1. Use the agent or earlier blocks to assemble the proposed action and supporting context. 2. Add the Human in the Loop block before the action that requires authorization. -3. Include a structured approve-or-reject field and any required comment fields. -4. Add a Condition immediately after the block. -5. Route only the explicit approval result to the consequential action. -6. Route rejection and unexpected values to a safe terminal, revision, or escalation path. -7. Write the decision to the appropriate system of record when an audit trail is required. +3. Configure the block's notification tool to alert the reviewer with the generated approval URL. +4. Include a structured approve-or-reject field and any required comment fields. +5. Add a Condition immediately after the block. +6. Route only the explicit approval result to the consequential action. +7. Route rejection and unexpected values to a safe terminal, revision, or escalation path. +8. Write the decision to the appropriate system of record when an audit trail is required. + +For an outbound-email workflow, earlier blocks can draft an email from approved CRM and account context. The Human in the Loop block can show the recipient, subject, body, and an approval field, then use its configured notification tool to alert the account owner with the generated approval URL. A downstream Condition routes an approved draft to the email action, while a rejected draft returns for revision or stops the run. The same pattern can protect a CRM write by showing proposed field changes and allowing the write operation only after approval. The [Sim Human in the Loop documentation](https://docs.sim.ai/workflows/blocks/human-in-the-loop) describes the block’s configuration and outputs. Teams comparing implementations can also read [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows). @@ -258,11 +283,13 @@ Use Wait when the workflow needs a fixed delay, such as retrying an operation af ## How do Sim and n8n approach human-in-the-loop approval? -Sim and n8n both provide ways to place human review before an AI-driven action, but buyers should compare the scope and routing model of each implementation. +Sim and [n8n](https://docs.n8n.io/build/integrate-ai/ai-examples/human-in-the-loop-for-tools) both provide ways to place human review before an AI-driven action, but buyers should compare the scope and routing model of each implementation. Sim treats Human in the Loop as a workflow block that pauses a run and resumes it with submitted form fields. This supports approval as well as requests for corrections, classifications, comments, or other structured input. The workflow author then uses a downstream Condition to enforce the reviewer’s decision. -As of October 2026, [n8n’s first-party documentation](https://docs.n8n.io/build/integrate-ai/ai-examples/human-in-the-loop-for-tools) describes human review before an AI Agent executes selected tool calls. That tool-level pattern is useful when the main control objective is authorizing a sensitive tool action. Buyers should evaluate whether they need tool-call approval, a general-purpose form-based pause, or both. +As of November 2026, [n8n’s first-party documentation](https://docs.n8n.io/build/integrate-ai/ai-examples/human-in-the-loop-for-tools) describes human review before an AI Agent executes selected tool calls. That tool-level pattern is useful when the main control objective is authorizing a sensitive tool action. Buyers should evaluate the complete control loop in any product: pausing, context presentation, authenticated review, structured input, conditional routing, timeout handling, and an inspectable record. They should also determine whether they need tool-call approval, a general-purpose form-based pause, or both. + +For a focused comparison of products for this use case, read [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows) rather than treating a notification or manual task as equivalent to approval. ## HITL as one piece of a larger agentic workflow @@ -270,6 +297,8 @@ Human review complements the other controls in an agent workflow, including retr Retrieval-augmented generation, commonly called RAG, grounds an agent's response in fetched documents or data. Grounding a response in relevant sources can reduce unsupported claims when retrieval returns accurate material and the model uses it correctly. Retrieval does not prevent every mistaken interpretation or unsafe tool call. Evaluators and guardrails can flag conditions they are configured to detect. A person can review cases that require contextual or policy judgment. Read more about [how retrieval-augmented generation works](https://www.sim.ai/library/what-is-retrieval-augmented-generation). +Human-in-the-loop during agent execution differs from human feedback during model training. A reviewer approving today's CRM update controls one run, while a labeler rating many responses produces data that may later train or evaluate a model. [IBM describes HITL across operation, supervision, and decision-making](https://www.ibm.com/think/topics/human-in-the-loop), while [Databricks describes participation in training, supervision, and decision-making](https://www.databricks.com/blog/human-in-the-loop). In Sim, the Human in the Loop block applies that broader principle to a defined operational decision. + Match each action to the least restrictive control that keeps its consequences acceptable. Read-only retrieval may run autonomously, while a costly refund may require approval and reversible actions may receive sampled review after execution. Sim represents these checkpoints through its [Human in the Loop block](https://docs.sim.ai/workflows/blocks/human-in-the-loop). Review the block in Sim's workflow builder to determine where a human decision adds useful control without delaying routine work, then follow the practical guide to [creating an AI agent](https://www.sim.ai/library/how-to-create-an-ai-agent) when you are ready to build the workflow. ## What are the limitations of human-in-the-loop AI agents? @@ -290,22 +319,43 @@ Common failure modes include: HITL should be one layer in a broader control system. Teams should also inspect workflow traces, monitor outcomes, evaluate agent behavior, apply least-privilege credentials, and test rejection paths. +## How should teams govern and audit human-in-the-loop agents? + +Human-in-the-loop governance should record who reviewed each request, what information they saw, what decision they made, when they made it, and what the agent did next. + +IT and security reviewers should confirm: + +- Which actions require a person and which can proceed automatically +- How the reviewer is selected and authenticated +- Whether only authorized roles can approve sensitive actions +- Whether the proposed action is preserved alongside the final decision +- Whether edits, rejections, timeouts, and escalations are recorded +- Whether a run can be traced from its trigger through review to the resulting system change +- What happens if no reviewer responds +- Who can change or bypass the approval control + +Human approval complements rather than replaces technical controls. Teams should still apply least-privilege credentials, input and output validation, tool restrictions, testing, and production observability. + ## How should teams design a reliable human-in-the-loop workflow? -A reliable human-in-the-loop workflow should minimize reviewer effort while making consequential decisions explicit, enforceable, and recoverable. +A reliable human-in-the-loop workflow should minimize reviewer effort while making consequential decisions explicit, enforceable, and recoverable. It is ready for deployment only when its authority, review triggers, routing, failure behavior, and evidence record have been tested end to end. -Use this design checklist: +Use this deployment checklist: -1. Define the action that requires control. -2. State why human judgment is needed. -3. Select the authorized reviewer or role. -4. Provide the evidence required for a decision. +1. Define which actions the agent may perform without review. +2. Define which actions always require approval. +3. Define confidence, validation, or exception rules that trigger review. +4. Select the authorized reviewer or role and provide the evidence required for a decision. 5. Ask for structured approval, rejection, revision, or escalation. 6. Keep the protected action downstream of an explicit condition. -7. Default unexpected, missing, and timed-out responses to a safe path. -8. Test every branch, including rejection and timeout. -9. Record the decision in the system that owns the audit requirement. -10. Review approval rates and remove unnecessary low-risk interruptions. +7. Route approval, rejection, revision, escalation, and timeout separately. +8. Confirm that rejection cannot accidentally reach the action path. +9. Test missing, malformed, contradictory, and timed-out reviewer responses. +10. Record the reviewer, decision, timestamp, proposed action, and final outcome in the system that owns the audit requirement. +11. Assign backup reviewers and define timeout behavior. +12. Monitor review volume, response time, rejection rate, overrides, and common correction reasons. +13. Use correction data to improve prompts, rules, retrieval, and tool permissions. +14. Re-test the approval path whenever tools, prompts, permissions, or routing rules change. The goal is not to maximize the number of approval steps. The goal is to place meaningful human judgment at the points where autonomous action would create unacceptable risk. diff --git a/apps/sim/ee/credential-groups/components/organization-person-connections.tsx b/apps/sim/ee/credential-groups/components/organization-person-connections.tsx index ede12506eaf..d3575fe1a7a 100644 --- a/apps/sim/ee/credential-groups/components/organization-person-connections.tsx +++ b/apps/sim/ee/credential-groups/components/organization-person-connections.tsx @@ -49,7 +49,7 @@ export function OrganizationPersonConnections({ person }: OrganizationPersonConn .map((connection) => ({ key: `mcp:${connection.mcpServerId}`, name: connection.name, - count: 1, + count: connection.count, icon: undefined, })), ] 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 ffe6507023e..8dd6fe7447a 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 @@ -29,13 +29,11 @@ import { isDesktopApp } from '@/lib/desktop' import { connectDesktopSource } from '@/lib/desktop/source-connect' import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups' -import { - organizationAccountsKeys, - useOrganizationAccounts, -} from '@/hooks/queries/organization-accounts' +import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' import { useSearchIntegrations } from '@/hooks/queries/search-integrations' import { useSlackSearchInstallations } from '@/hooks/queries/slack-search' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' +import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys' import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' const CHANNEL_NAME = 'slack-managed-users' diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index 7261870c136..347bef20b2b 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -43,6 +43,7 @@ import { ZaiIcon, } from '@/components/icons' import { NebiusIcon } from '@/components/icons/nebius' +import { ThinkingMachinesIcon } from '@/components/icons/thinking-machines' import type { OrganizationUsageBreakdown, OrganizationUsageBreakdownRow, @@ -90,6 +91,7 @@ const PROVIDER_ICONS: Readonly { - const { childWorkspaceId, failures, requestId = 'unknown' } = params + const { childWorkspaceId, failures, requestId = generateRequestId() } = params if (failures.length === 0) return { cleared: 0, clearingFailed: false } const failedKnowledgeBaseIds = failures.flatMap((failure) => @@ -158,10 +159,11 @@ export async function clearFailedForkResourceReferences(params: { docIds.push(docId) } if (failure.documentChildIds.length > 0) cleanupCount += 1 - logger.warn( - `[${requestId}] Keeping a failed copied knowledge base that contains non-fork documents`, - { childWorkspaceId, childKnowledgeBaseId: failure.childId } - ) + logger.warn('Keeping a failed copied knowledge base that contains non-fork documents', { + requestId, + childWorkspaceId, + childKnowledgeBaseId: failure.childId, + }) continue } markFailed('knowledge-base', failure.childId) @@ -183,7 +185,8 @@ export async function clearFailedForkResourceReferences(params: { affectedWorkflowIds = await clearFailedReferencesInWorkflows(childWorkspaceId, failedByKind) } catch (error) { clearingSucceeded = false - logger.error(`[${requestId}] Failed to clear references for failed fork resources`, { + logger.error('Failed to clear references for failed fork resources', { + requestId, childWorkspaceId, error: getErrorMessage(error), }) @@ -203,7 +206,8 @@ export async function clearFailedForkResourceReferences(params: { await clearFailedReferencesInDeploymentVersions(deployedSweepIds, failedByKind, requestId) } catch (error) { clearingSucceeded = false - logger.error(`[${requestId}] Failed to clear references in fork deployment versions`, { + logger.error('Failed to clear references in fork deployment versions', { + requestId, childWorkspaceId, error: getErrorMessage(error), }) @@ -215,15 +219,13 @@ export async function clearFailedForkResourceReferences(params: { // (now content-less) resource - and ONLY when clearing succeeded, so a clear failure never // turns an empty placeholder into a dangling reference to a deleted row. if (!clearingSucceeded) { - logger.warn( - `[${requestId}] Skipping fork resource placeholder drop after a reference-clear failure`, - { - childWorkspaceId, - tables: tableIds.length, - knowledgeBases: kbIds.length, - documents: docIds.length, - } - ) + logger.warn('Skipping fork resource placeholder drop after a reference-clear failure', { + requestId, + childWorkspaceId, + tables: tableIds.length, + knowledgeBases: kbIds.length, + documents: docIds.length, + }) return { cleared: 0, clearingFailed: true } } try { @@ -239,7 +241,8 @@ export async function clearFailedForkResourceReferences(params: { await db.delete(document).where(inArray(document.id, docIds)) } } catch (error) { - logger.error(`[${requestId}] Failed to drop orphaned fork resource placeholders`, { + logger.error('Failed to drop orphaned fork resource placeholders', { + requestId, childWorkspaceId, error: getErrorMessage(error), }) @@ -407,7 +410,8 @@ export async function clearFailedReferencesInDeploymentVersions( } } catch (error) { failures.push(error) - logger.error(`[${requestId}] Failed to clear references in deployment versions`, { + logger.error('Failed to clear references in deployment versions', { + requestId, workflowId, error: getErrorMessage(error), }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts b/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts index 0b3551d6126..11e7a264acd 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' import { finishBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' import { clearFailedForkResourceReferences } from '@/ee/workspace-forking/lib/copy/cleanup-failed' import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files' @@ -219,7 +220,7 @@ export async function scheduleForkContentCopy( payload: ForkContentCopyPayload, options: { detachedLabel: string; requestId?: string } ): Promise { - const { detachedLabel, requestId = 'unknown' } = options + const { detachedLabel, requestId = generateRequestId() } = options try { if (isTriggerDevEnabled) { const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ @@ -234,7 +235,8 @@ export async function scheduleForkContentCopy( runDetached(detachedLabel, () => runForkContentCopy(payload)) } } catch (error) { - logger.error(`[${requestId}] Failed to schedule fork content copy`, { + logger.error('Failed to schedule fork content copy', { + requestId, detachedLabel, error: getErrorMessage(error), }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts index fb716b91749..4a33a1ade82 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts @@ -183,7 +183,8 @@ export async function copyForkChatDeployments(params: { } if (inserts.length > 0) { await tx.insert(chat).values(inserts) - logger.info(`[${requestId ?? 'unknown'}] Carried ${inserts.length} chat deployment(s)`, { + logger.info(`Carried ${inserts.length} chat deployment(s)`, { + requestId, identifiers: inserts.map((row) => row.identifier), }) } diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts index 9a17cccd52b..8598d9dda33 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts @@ -9,6 +9,7 @@ import { lockWorkspaceStorageForMutationInTx, resolveStorageBillingContext, } from '@/lib/billing/storage' +import { generateRequestId } from '@/lib/core/utils/request' import type { DbOrTx } from '@/lib/db/types' import { allocateUniqueWorkspaceFileName, @@ -270,7 +271,7 @@ export async function planForkFileCopies(params: { */ export async function executeForkFileBlobCopies( blobTasks: BlobCopyTask[], - requestId = 'unknown', + requestId = generateRequestId(), contentRefMaps?: ForkContentRefMaps, control?: ForkCopyControl ): Promise<{ copied: number; failed: number; failedTargetKeys: string[] }> { @@ -286,7 +287,8 @@ export async function executeForkFileBlobCopies( rethrowForkCopyInterruption(error, control) for (const task of taskPage) { failedTargetKeys.push(task.targetKey) - logger.warn(`[${requestId}] Failed to check copied file replay state`, { + logger.warn('Failed to check copied file replay state', { + requestId, targetKey: task.targetKey, error: getErrorMessage(error), }) @@ -326,13 +328,11 @@ export async function executeForkFileBlobCopies( if (rewritten !== text) body = Buffer.from(rewritten, 'utf8') } catch (error) { rethrowForkCopyInterruption(error, control) - logger.warn( - `[${requestId}] Failed to rewrite markdown blob content; copying raw bytes`, - { - targetKey: task.targetKey, - error: getErrorMessage(error), - } - ) + logger.warn('Failed to rewrite markdown blob content; copying raw bytes', { + requestId, + targetKey: task.targetKey, + error: getErrorMessage(error), + }) } } assertForkCopyActive(control) @@ -447,7 +447,8 @@ export async function executeForkFileBlobCopies( await completeForkCopyResource(control, `file:${task.targetFileId}`) copied += 1 if (targetOriginalName !== task.fileName) { - logger.warn(`[${requestId}] Copied file renamed to avoid a target name collision`, { + logger.warn('Copied file renamed to avoid a target name collision', { + requestId, targetKey: task.targetKey, folderId: task.targetFolderId ?? null, from: task.fileName, @@ -457,7 +458,8 @@ export async function executeForkFileBlobCopies( } catch (error) { rethrowForkCopyInterruption(error, control) failedTargetKeys.push(task.targetKey) - logger.warn(`[${requestId}] Failed to copy file blob during fork`, { + logger.warn('Failed to copy file blob during fork', { + requestId, targetKey: task.targetKey, error: getErrorMessage(error), }) @@ -478,7 +480,8 @@ export async function executeForkFileBlobCopies( await deleteFile({ key: task.targetKey, context: task.context }) } } catch (cleanupError) { - logger.warn(`[${requestId}] Failed to clean up unfinalized copied file blob`, { + logger.warn('Failed to clean up unfinalized copied file blob', { + requestId, targetKey: task.targetKey, error: getErrorMessage(cleanupError), }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index fc5e6eee8ea..584c8b4c92e 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -42,6 +42,7 @@ import { type StorageBillingContext, } from '@/lib/billing/storage' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' import type { DbOrTx } from '@/lib/db/types' import { type DurableSecretProvenance, @@ -1113,7 +1114,7 @@ export async function copyForkResourceContent(params: { requestId?: string control?: ForkCopyControl }): Promise<{ copied: number; failed: number; failures: ForkFailedResource[] }> { - const { contentPlan, contentRefMaps, control, requestId = 'unknown' } = params + const { contentPlan, contentRefMaps, control, requestId = generateRequestId() } = params const { childWorkspaceId, userId } = contentPlan let copiedResources = 0 @@ -1143,7 +1144,8 @@ export async function copyForkResourceContent(params: { }) } catch (mappingCleanupError) { rethrowForkCopyInterruption(mappingCleanupError, control) - logger.error(`[${requestId}] Failed to clean mapping for a failed copied document`, { + logger.error('Failed to clean mapping for a failed copied document', { + requestId, childDocumentId, error: getErrorMessage(mappingCleanupError), }) @@ -1183,17 +1185,20 @@ export async function copyForkResourceContent(params: { // the same child id again, and the cleanup keys failed ids by kind in a Set. await dropCopiedDocumentMapping(childDocumentId) staleChildIds.push(childDocumentId) - logger.warn( - `[${requestId}] Dropping a fork placeholder planned for a connector-managed document`, - { sourceDocumentId: id, childDocumentId, childKnowledgeBaseId: kb.childId } - ) + logger.warn('Dropping a fork placeholder planned for a connector-managed document', { + requestId, + sourceDocumentId: id, + childDocumentId, + childKnowledgeBaseId: kb.childId, + }) } return staleChildIds } catch (error) { rethrowForkCopyInterruption(error, control) logger.error( - `[${requestId}] Failed to reconcile fork placeholders planned for connector-managed documents`, + 'Failed to reconcile fork placeholders planned for connector-managed documents', { + requestId, sourceKnowledgeBaseId: kb.sourceId, childKnowledgeBaseId: kb.childId, error: getErrorMessage(error), @@ -1223,14 +1228,16 @@ export async function copyForkResourceContent(params: { ) const skipped = Number(row?.total ?? 0) if (skipped === 0) return - logger.info(`[${requestId}] Skipped connector-managed documents in a copied knowledge base`, { + logger.info('Skipped connector-managed documents in a copied knowledge base', { + requestId, sourceKnowledgeBaseId: kb.sourceId, childKnowledgeBaseId: kb.childId, skipped, }) } catch (error) { rethrowForkCopyInterruption(error, control) - logger.warn(`[${requestId}] Failed to count the documents a copied knowledge base skipped`, { + logger.warn('Failed to count the documents a copied knowledge base skipped', { + requestId, sourceKnowledgeBaseId: kb.sourceId, error: getErrorMessage(error), }) @@ -1349,7 +1356,8 @@ export async function copyForkResourceContent(params: { rethrowForkCopyInterruption(error, control) failedResources += 1 failures.push({ kind: 'table', childId: table.childId }) - logger.warn(`[${requestId}] Failed to copy table rows during fork`, { + logger.warn('Failed to copy table rows during fork', { + requestId, sourceTableId: table.sourceId, error: getErrorMessage(error), }) @@ -1469,7 +1477,8 @@ export async function copyForkResourceContent(params: { await rollbackCopiedKbDocuments(kb.childId, childWorkspaceId, control) } catch (rollbackError) { rethrowForkCopyInterruption(rollbackError, control) - logger.error(`[${requestId}] Failed to roll back copied KB storage accounting`, { + logger.error('Failed to roll back copied KB storage accounting', { + requestId, childKnowledgeBaseId: kb.childId, error: getErrorMessage(rollbackError), }) @@ -1487,7 +1496,8 @@ export async function copyForkResourceContent(params: { ) } catch (mappingCleanupError) { rethrowForkCopyInterruption(mappingCleanupError, control) - logger.error(`[${requestId}] Failed to clean mappings for a failed copied KB`, { + logger.error('Failed to clean mappings for a failed copied KB', { + requestId, childKnowledgeBaseId: kb.childId, error: getErrorMessage(mappingCleanupError), }) @@ -1499,7 +1509,8 @@ export async function copyForkResourceContent(params: { childId: kb.childId, documentChildIds: Object.values(kb.documentIdMap), }) - logger.warn(`[${requestId}] Failed to copy knowledge base content during fork`, { + logger.warn('Failed to copy knowledge base content during fork', { + requestId, sourceKnowledgeBaseId: kb.sourceId, error: getErrorMessage(error), }) @@ -1567,7 +1578,8 @@ export async function copyForkResourceContent(params: { await dropCopiedDocumentMapping(docEntry.childDocId) failedResources += 1 failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId }) - logger.warn(`[${requestId}] Failed to copy document into mapped KB during sync`, { + logger.warn('Failed to copy document into mapped KB during sync', { + requestId, sourceDocumentId: docEntry.sourceDocId, error: getErrorMessage(error), }) @@ -1609,13 +1621,11 @@ export async function copyForkResourceContent(params: { await completeForkCopyResource(control, `skill:${row.id}`) } catch (error) { rethrowForkCopyInterruption(error, control) - logger.warn( - `[${requestId}] Failed to rewrite copied skill content; keeping source links`, - { - childSkillId: row.id, - error: getErrorMessage(error), - } - ) + logger.warn('Failed to rewrite copied skill content; keeping source links', { + requestId, + childSkillId: row.id, + error: getErrorMessage(error), + }) } }) afterId = rows[rows.length - 1].id diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 847f2ec2984..82b4bb95c6f 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { generateRequestId } from '@/lib/core/utils/request' import type { DbOrTx } from '@/lib/db/types' import { buildFolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { assertFolderCollectionHasRoom } from '@/lib/folders/queries' @@ -499,7 +500,7 @@ export async function copyWorkflowStateIntoTarget( nameRegistry, resolveBlockId, triggerPathByBlockId, - requestId = 'unknown', + requestId = generateRequestId(), } = params const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null @@ -651,7 +652,8 @@ export async function copyWorkflowStateIntoTarget( const newSource = blockIdMapping.get(edge.source) const newTarget = blockIdMapping.get(edge.target) if (!newSource || !newTarget) { - logger.warn(`[${requestId}] Skipping edge with unmapped block reference during fork copy`, { + logger.warn('Skipping edge with unmapped block reference during fork copy', { + requestId, edgeId: edge.id, }) return [] diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 3944e1b3d0b..9a6df23e071 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -7,6 +7,7 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { Workspace } from '@/lib/api/contracts/workspaces' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { generateRequestId } from '@/lib/core/utils/request' import { requireForkProject } from '@/lib/projects/membership' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' @@ -146,7 +147,7 @@ const FORK_KIND_TO_RESOURCE_TYPE: Partial { - const { source, policy, userId, requestId = 'unknown' } = params + const { source, policy, userId, requestId = generateRequestId() } = params const admission = params.admission if (admission) { const receipt = await findWorkspaceOperationReceipt( @@ -560,7 +561,8 @@ export async function createFork(params: CreateForkParams): Promise { const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params - const requestId = params.requestId ?? 'unknown' + const requestId = params.requestId ?? generateRequestId() const admission = params.admission const revisionScope = { sourceWorkspaceId, targetWorkspaceId, edge } if (admission) { @@ -701,8 +702,9 @@ export async function promoteFork(params: PromoteForkParams): Promise 0) { logger.info( - `[${requestId}] Promote leaving ${plan.excludedTargets.length} sync-excluded target workflow(s) untouched`, + `Promote leaving ${plan.excludedTargets.length} sync-excluded target workflow(s) untouched`, { + requestId, sourceWorkspaceId, targetWorkspaceId, excludedTargets: plan.excludedTargets.map((target) => target.name), @@ -1235,8 +1237,8 @@ export async function promoteFork(params: PromoteForkParams): Promise ({ id: item.sourceWorkflowId, name: item.sourceMeta.name })) if (skippedItems.length > 0) { logger.warn( - `[${requestId}] Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`, - { sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length } + `Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`, + { requestId, sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length } ) } @@ -1368,7 +1370,8 @@ export async function promoteFork(params: PromoteForkParams): Promise 0) { - logger.warn(`[${requestId}] Promote wrote state but some targets failed to deploy`, { + logger.warn('Promote wrote state but some targets failed to deploy', { + requestId, sourceWorkspaceId, targetWorkspaceId, deployFailed: deployFailures.length, @@ -1537,21 +1544,24 @@ export async function promoteFork(params: PromoteForkParams): Promise 0) { - logger.warn(`[${requestId}] Promote deploys emitted warnings`, { deployWarnings }) + logger.warn('Promote deploys emitted warnings', { requestId, deployWarnings }) } if (txResult.skippedItems.length > 0) { - logger.warn(`[${requestId}] Promote skipped undeployed source workflows`, { + logger.warn('Promote skipped undeployed source workflows', { + requestId, skipped: txResult.skippedItems.map((item) => item.name), }) } if (txResult.needsConfiguration.length > 0) { - logger.warn(`[${requestId}] Promote left required dependent fields needing configuration`, { + logger.warn('Promote left required dependent fields needing configuration', { + requestId, sourceWorkspaceId, targetWorkspaceId, needsConfiguration: txResult.needsConfiguration.map((n) => n.workflowName), }) } - logger.info(`[${requestId}] Promoted ${sourceWorkspaceId} -> ${targetWorkspaceId}`, { + logger.info(`Promoted ${sourceWorkspaceId} -> ${targetWorkspaceId}`, { + requestId, updated: txResult.updated, created: txResult.created, archived: txResult.archived, diff --git a/apps/sim/ee/workspace-forking/lib/promote/rollback.ts b/apps/sim/ee/workspace-forking/lib/promote/rollback.ts index 281d2956ebc..56d40f157f5 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/rollback.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/rollback.ts @@ -258,10 +258,11 @@ export async function rollbackFork(params: RollbackForkParams): Promise 0) { - logger.warn( - `[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`, - { - targetWorkspaceId, - skipped: Array.from(skipped), - } - ) + logger.warn(`Rollback skipped ${skipped.size} workflow(s) no longer in the database`, { + requestId, + targetWorkspaceId, + skipped: Array.from(skipped), + }) } // Notify connected canvases to adopt the restored state (reactivated drafts + the @@ -373,7 +373,8 @@ export async function rollbackFork(params: RollbackForkParams): Promise { - const name = str(inputs.companyName) - if (!name) return null - // `required` makes PDL 404 (free) when the match has no website, - // instead of charging a credit for a match we'd discard as a no-match. - return { name, required: 'website' } + const companyName = str(inputs.companyName) + return companyName ? { enrichmentType: 'company_domain', companyName } : null }, mapOutput: (output) => { - const company = output.company as Record | undefined - const domain = normalizeDomain(company?.website) - return domain ? { domain } : null - }, - }), - toolProvider({ - id: 'datagma', - label: 'Datagma', - toolId: 'datagma_enrich_company', - buildParams: (inputs) => { - // Datagma's `data` accepts a company name and returns its website. - const data = str(inputs.companyName) - if (!data) return null - return { data } - }, - mapOutput: (output) => { - const domain = normalizeDomain(output.website) + const domain = normalizeDomain(output.domain) return domain ? { domain } : null }, }), diff --git a/apps/sim/enrichments/company-info/company-info.ts b/apps/sim/enrichments/company-info/company-info.ts index 43fbde12a23..6ca84250538 100644 --- a/apps/sim/enrichments/company-info/company-info.ts +++ b/apps/sim/enrichments/company-info/company-info.ts @@ -3,14 +3,6 @@ import { filterUndefined } from '@sim/utils/object' import { normalizeDomain, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' -/** - * Company Info enrichment. Looks up a company by domain, trying Hunter first - * (free) then People Data Labs, then Datagma and LeadMagic as fallbacks. Outputs - * are limited to the fields the providers reliably return — employee count and - * description — so the result stays consistent regardless of which provider fills - * the cell. `employeeCount` is a string so Hunter's range bucket (e.g. `"11-50"`), - * PDL's exact count, and LeadMagic's range all map onto the same column. - */ export const companyInfoEnrichment: EnrichmentConfig = { id: 'company-info', name: 'Company Info', @@ -23,69 +15,16 @@ export const companyInfoEnrichment: EnrichmentConfig = { ], providers: [ toolProvider({ - id: 'hunter', - label: 'Hunter', - toolId: 'hunter_companies_find', - buildParams: (inputs) => { - const domain = normalizeDomain(inputs.domain) - if (!domain) return null - return { domain } - }, - mapOutput: (output) => { - return filterUndefined({ - employeeCount: str(output.size) || undefined, - description: str(output.description) || undefined, - }) - }, - }), - toolProvider({ - id: 'pdl', - label: 'People Data Labs', - toolId: 'pdl_company_enrich', - buildParams: (inputs) => { - const website = normalizeDomain(inputs.domain) - if (!website) return null - // `required` makes PDL 404 (free) when neither field we extract is - // present, instead of charging a credit for a match we'd discard. - return { website, required: 'employee_count OR summary' } - }, - mapOutput: (output) => { - const company = output.company as Record | undefined - return filterUndefined({ - employeeCount: str(company?.employee_count) || undefined, - description: str(company?.summary) || undefined, - }) - }, - }), - toolProvider({ - id: 'datagma', - label: 'Datagma', - toolId: 'datagma_enrich_company', - buildParams: (inputs) => { - const data = normalizeDomain(inputs.domain) - if (!data) return null - return { data } - }, - mapOutput: (output) => { - return filterUndefined({ - employeeCount: str(output.companySize) || undefined, - description: str(output.shortDescription) || undefined, - }) - }, - }), - toolProvider({ - id: 'leadmagic', - label: 'LeadMagic', - toolId: 'leadmagic_company_search', + id: 'firecrawl', + label: 'Firecrawl', + toolId: 'firecrawl_enrich', buildParams: (inputs) => { const companyDomain = normalizeDomain(inputs.domain) - if (!companyDomain) return null - return { company_domain: companyDomain } + return companyDomain ? { enrichmentType: 'company', companyDomain } : null }, mapOutput: (output) => { - // Prefer the headcount range to match Hunter's bucket style; fall back to the exact count. return filterUndefined({ - employeeCount: str(output.employeeRange) || str(output.employeeCount) || undefined, + employeeCount: str(output.employeeCount) || undefined, description: str(output.description) || undefined, }) }, diff --git a/apps/sim/enrichments/email-verification/email-verification.ts b/apps/sim/enrichments/email-verification/email-verification.ts index 7edd4f5453f..ae812fe00d4 100644 --- a/apps/sim/enrichments/email-verification/email-verification.ts +++ b/apps/sim/enrichments/email-verification/email-verification.ts @@ -2,27 +2,6 @@ import { ShieldCheck } from '@sim/emcn/icons' import { str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' -/** - * Verdicts that say nothing about the mailbox; the cascade falls through to the - * next verifier on these. MillionVerifier reports `unverified` (and may report - * `error`) when it could not complete a check. - */ -const INCONCLUSIVE_STATUSES = new Set(['unknown', 'unverified', 'error']) - -function mapVerdict(output: Record) { - const status = str(output.status) - if (!status || INCONCLUSIVE_STATUSES.has(status)) return null - return { status, deliverable: output.deliverable === true } -} - -/** - * Email Verification enrichment. Checks an email address's deliverability via a - * verifier waterfall — ZeroBounce first (highest coverage), then NeverBounce, - * then MillionVerifier, then Enrow. A provider that returns a definitive verdict - * (valid / invalid / catch_all / disposable / etc.) fills the cell; an - * inconclusive one falls through to the next so the row gets the most confident - * answer available. All providers support hosted keys. - */ export const emailVerificationEnrichment: EnrichmentConfig = { id: 'email-verification', name: 'Email Verification', @@ -43,45 +22,10 @@ export const emailVerificationEnrichment: EnrichmentConfig = { if (!email) return null return { email } }, - mapOutput: mapVerdict, - }), - toolProvider({ - id: 'neverbounce', - label: 'NeverBounce', - toolId: 'neverbounce_verify_email', - buildParams: (inputs) => { - const email = str(inputs.email) - if (!email) return null - return { email } - }, - mapOutput: mapVerdict, - }), - toolProvider({ - id: 'millionverifier', - label: 'MillionVerifier', - toolId: 'millionverifier_verify_email', - buildParams: (inputs) => { - const email = str(inputs.email) - if (!email) return null - return { email } - }, - mapOutput: mapVerdict, - }), - toolProvider({ - id: 'enrow', - label: 'Enrow', - toolId: 'enrow_verify_email', - buildParams: (inputs) => { - const email = str(inputs.email) - if (!email) return null - return { email } - }, mapOutput: (output) => { - // Enrow returns a "valid" / "invalid" qualifier; anything else is inconclusive. - const qualification = str(output.qualification).toLowerCase() - if (qualification === 'valid') return { status: 'valid', deliverable: true } - if (qualification === 'invalid') return { status: 'invalid', deliverable: false } - return null + const status = str(output.status) + if (!status || status === 'unknown') return null + return { status, deliverable: output.deliverable === true } }, }), ], diff --git a/apps/sim/enrichments/linkedin-profile/linkedin-profile.test.ts b/apps/sim/enrichments/linkedin-profile/linkedin-profile.test.ts deleted file mode 100644 index 54dd2a58090..00000000000 --- a/apps/sim/enrichments/linkedin-profile/linkedin-profile.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { linkedinProfileEnrichment } from '@/enrichments/linkedin-profile/linkedin-profile' -import type { EnrichmentProvider } from '@/enrichments/types' - -function provider(id: string): EnrichmentProvider { - const match = linkedinProfileEnrichment.providers.find((candidate) => candidate.id === id) - if (!match) throw new Error(`Provider ${id} not found in linkedin-profile cascade`) - return match -} - -const allInputs = { - fullName: 'Jane Doe', - email: 'jane@acme.com', - companyName: 'Acme Inc', - companyDomain: 'https://www.acme.com/careers', -} - -describe('linkedin-profile enrichment cascade', () => { - it('uses email-only reverse lookup providers only when email is available', () => { - expect(provider('findymail').buildParams(allInputs)).toEqual({ email: 'jane@acme.com' }) - expect(provider('leadmagic').buildParams(allInputs)).toEqual({ work_email: 'jane@acme.com' }) - expect(provider('findymail').buildParams({ fullName: 'Jane Doe' })).toBeNull() - expect(provider('leadmagic').buildParams({ fullName: 'Jane Doe' })).toBeNull() - }) - - it('treats Prospeo NO_MATCH as a clean miss without hiding real errors', () => { - const prospeo = provider('prospeo') - expect( - prospeo.projectFailure({ - error: 'NO_MATCH', - output: { status: 400, data: { error: true, error_code: 'NO_MATCH' } }, - }) - ).toEqual({ status: 'no_match' }) - expect( - prospeo.projectFailure({ - error: 'INVALID_API_KEY', - output: { status: 400, data: { error: true, error_code: 'INVALID_API_KEY' } }, - }) - ).toEqual({ status: 'error', error: 'INVALID_API_KEY' }) - }) - - it('requires PDL to return a LinkedIn URL and uses the strongest company key', () => { - const pdl = provider('pdl') - expect(pdl.buildParams(allInputs)).toEqual({ - email: 'jane@acme.com', - name: 'Jane Doe', - company: 'acme.com', - min_likelihood: 6, - required: 'linkedin_url', - }) - expect(pdl.buildParams({ fullName: 'Jane Doe', companyName: 'Acme Inc' })).toEqual({ - name: 'Jane Doe', - company: 'Acme Inc', - min_likelihood: 6, - required: 'linkedin_url', - }) - expect(pdl.buildParams({ fullName: 'Jane Doe' })).toBeNull() - }) -}) diff --git a/apps/sim/enrichments/linkedin-profile/linkedin-profile.ts b/apps/sim/enrichments/linkedin-profile/linkedin-profile.ts index dff3fb48437..8d7c78f41b1 100644 --- a/apps/sim/enrichments/linkedin-profile/linkedin-profile.ts +++ b/apps/sim/enrichments/linkedin-profile/linkedin-profile.ts @@ -1,6 +1,4 @@ import { LinkedIn } from '@sim/emcn/icons' -import { filterUndefined } from '@sim/utils/object' -import { projectProspeoEnrichmentFailure } from '@/enrichments/provider-failures/prospeo' import { normalizeDomain, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' @@ -24,13 +22,6 @@ function normalizeLinkedInProfileUrl(value: unknown): string | null { return `https://www.linkedin.com/in/${slug}` } -/** - * LinkedIn Profile enrichment. Resolves a person's LinkedIn URL from a full - * name plus any available email or company identifiers. Email-specific reverse - * lookups run first, followed by broader person enrichment providers. Every - * provider supports hosted keys, and only canonical person-profile URLs are - * accepted as matches. - */ export const linkedinProfileEnrichment: EnrichmentConfig = { id: 'linkedin-profile', name: 'LinkedIn Profile', @@ -45,93 +36,19 @@ export const linkedinProfileEnrichment: EnrichmentConfig = { outputs: [{ id: 'linkedinUrl', name: 'linkedin_url', type: 'string' }], providers: [ toolProvider({ - id: 'findymail', - label: 'Findymail', - toolId: 'findymail_reverse_email_lookup', - buildParams: (inputs) => { - const email = str(inputs.email) - return email ? { email } : null - }, - mapOutput: (output) => { - const linkedinUrl = normalizeLinkedInProfileUrl(output.linkedin_url) - return linkedinUrl ? { linkedinUrl } : null - }, - }), - toolProvider({ - id: 'datagma', - label: 'Datagma', - toolId: 'datagma_enrich_person', - buildParams: (inputs) => { - const email = str(inputs.email) - if (email) return { data: email, personFull: false, phoneFull: false } - - const fullName = str(inputs.fullName) - const companyKeyword = str(inputs.companyName) || normalizeDomain(inputs.companyDomain) - if (!fullName || !companyKeyword) return null - return { data: fullName, companyKeyword, personFull: false, phoneFull: false } - }, - mapOutput: (output) => { - const linkedinUrl = normalizeLinkedInProfileUrl(output.linkedInUrl) - return linkedinUrl ? { linkedinUrl } : null - }, - }), - toolProvider({ - id: 'prospeo', - label: 'Prospeo', - toolId: 'prospeo_enrich_person', + id: 'firecrawl', + label: 'Firecrawl', + toolId: 'firecrawl_enrich', buildParams: (inputs) => { const email = str(inputs.email) const fullName = str(inputs.fullName) + const companyDomain = normalizeDomain(inputs.companyDomain) const companyName = str(inputs.companyName) - const companyWebsite = normalizeDomain(inputs.companyDomain) - if (!email && !(fullName && (companyName || companyWebsite))) return null - return filterUndefined({ - email: email || undefined, - full_name: fullName || undefined, - company_name: companyName || undefined, - company_website: companyWebsite || undefined, - }) - }, - projectFailure: projectProspeoEnrichmentFailure, - mapOutput: (output) => { - const person = output.person as Record | undefined - const linkedinUrl = normalizeLinkedInProfileUrl(person?.linkedin_url) - return linkedinUrl ? { linkedinUrl } : null - }, - }), - toolProvider({ - id: 'leadmagic', - label: 'LeadMagic', - toolId: 'leadmagic_email_to_profile', - buildParams: (inputs) => { - const email = str(inputs.email) - return email ? { work_email: email } : null - }, - mapOutput: (output) => { - const linkedinUrl = normalizeLinkedInProfileUrl(output.profile_url) - return linkedinUrl ? { linkedinUrl } : null - }, - }), - toolProvider({ - id: 'pdl', - label: 'People Data Labs', - toolId: 'pdl_person_enrich', - buildParams: (inputs) => { - const email = str(inputs.email) - const fullName = str(inputs.fullName) - const company = normalizeDomain(inputs.companyDomain) || str(inputs.companyName) - if (!email && !(fullName && company)) return null - return filterUndefined({ - email: email || undefined, - name: fullName || undefined, - company: company || undefined, - min_likelihood: 6, - required: 'linkedin_url', - }) + if (!email && !(fullName && (companyDomain || companyName))) return null + return { enrichmentType: 'linkedin', email, fullName, companyDomain, companyName } }, mapOutput: (output) => { - const person = output.person as Record | undefined - const linkedinUrl = normalizeLinkedInProfileUrl(person?.linkedin_url) + const linkedinUrl = normalizeLinkedInProfileUrl(output.linkedinUrl) return linkedinUrl ? { linkedinUrl } : null }, }), diff --git a/apps/sim/enrichments/phone-number/phone-number.test.ts b/apps/sim/enrichments/phone-number/phone-number.test.ts deleted file mode 100644 index e7695c93f81..00000000000 --- a/apps/sim/enrichments/phone-number/phone-number.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { phoneNumberEnrichment } from '@/enrichments/phone-number/phone-number' -import type { EnrichmentProvider } from '@/enrichments/types' - -function provider(id: string): EnrichmentProvider { - const p = phoneNumberEnrichment.providers.find((x) => x.id === id) - if (!p) throw new Error(`Provider ${id} not found in phone-number cascade`) - return p -} - -const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' } -const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' } - -describe('phone-number enrichment cascade', () => { - describe('findymail', () => { - const p = provider('findymail') - it('keys off the LinkedIn URL and skips without one', () => { - expect(p.toolId).toBe('findymail_find_phone') - expect(p.buildParams(linkedinOnly)).toEqual({ - linkedin_url: 'https://linkedin.com/in/johndoe', - }) - expect(p.buildParams(nameDomain)).toBeNull() - expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' }) - expect(p.mapOutput({ phone: null })).toBeNull() - }) - }) - - describe('prospeo (opportunistic)', () => { - const p = provider('prospeo') - it('recognizes only Prospeo NO_MATCH as a clean miss', () => { - expect( - p.projectFailure({ - error: 'NO_MATCH', - output: { status: 400, data: { error: true, error_code: 'NO_MATCH' } }, - }) - ).toEqual({ status: 'no_match' }) - expect( - p.projectFailure({ - error: 'INVALID_API_KEY', - output: { status: 400, data: { error: true, error_code: 'INVALID_API_KEY' } }, - }) - ).toEqual({ status: 'error', error: 'INVALID_API_KEY' }) - expect( - p.projectFailure({ - error: 'Bad Request', - output: { status: 400, data: { error: true } }, - }) - ).toEqual({ status: 'error', error: 'Bad Request' }) - }) - }) -}) diff --git a/apps/sim/enrichments/phone-number/phone-number.ts b/apps/sim/enrichments/phone-number/phone-number.ts index 7d71ee5fe9c..1212c27a213 100644 --- a/apps/sim/enrichments/phone-number/phone-number.ts +++ b/apps/sim/enrichments/phone-number/phone-number.ts @@ -1,18 +1,7 @@ import { Phone } from '@sim/emcn/icons' -import { filterUndefined } from '@sim/utils/object' -import { projectProspeoEnrichmentFailure } from '@/enrichments/provider-failures/prospeo' -import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments/providers' +import { normalizeDomain, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' -/** - * Phone Number enrichment. Finds a contact's phone number from a full name plus - * any available identifiers (company domain, LinkedIn URL) via a waterfall: - * People Data Labs (name + company or LinkedIn) → Wiza reveal → Findymail - * (LinkedIn) → Prospeo mobile → LeadMagic (LinkedIn) → Datagma (LinkedIn). Each - * provider opportunistically uses whatever identifiers the row provides and - * self-skips when it has none usable, so adding more inputs widens coverage - * without reordering. First phone wins; all providers support hosted keys. - */ export const phoneNumberEnrichment: EnrichmentConfig = { id: 'phone-number', name: 'Phone Number', @@ -26,122 +15,15 @@ export const phoneNumberEnrichment: EnrichmentConfig = { outputs: [{ id: 'phone', name: 'phone', type: 'string' }], providers: [ toolProvider({ - id: 'pdl', - label: 'People Data Labs', - toolId: 'pdl_person_enrich', + id: 'firecrawl', + label: 'Firecrawl', + toolId: 'firecrawl_enrich', buildParams: (inputs) => { - const name = str(inputs.fullName) - const company = normalizeDomain(inputs.companyDomain) - const linkedin = str(inputs.linkedinUrl) - // A bare name is not a valid PDL match and scores below min_likelihood 6. - if (!name || !(company || linkedin)) return null - // `required` makes PDL 404 (free) when the profile has no phone, - // instead of charging a credit for a match we'd discard as a no-match. - return filterUndefined({ - name, - company: company || undefined, - profile: linkedin || undefined, - min_likelihood: 6, - required: 'phone_numbers OR mobile_phone', - }) - }, - mapOutput: (output) => { - const person = output.person as Record | undefined - const phone = str(person?.mobile_phone) || firstNonEmpty(person?.phone_numbers) - return phone ? { phone } : null - }, - }), - toolProvider({ - id: 'wiza', - label: 'Wiza', - toolId: 'wiza_individual_reveal', - buildParams: (inputs) => { - const linkedin = str(inputs.linkedinUrl) const fullName = str(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - // Needs a LinkedIn URL or a name+domain pair; skip otherwise. - if (!linkedin && !(fullName && domain)) return null - // 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present. - return filterUndefined({ - profile_url: linkedin || undefined, - full_name: fullName || undefined, - domain: domain || undefined, - enrichment_level: 'phone', - }) - }, - mapOutput: (output) => { - const phones = Array.isArray(output.phones) - ? (output.phones as Record[]) - : [] - const phone = str(output.mobile_phone) || str(output.phone_number) || str(phones[0]?.number) - return phone ? { phone } : null - }, - }), - toolProvider({ - id: 'findymail', - label: 'Findymail', - toolId: 'findymail_find_phone', - buildParams: (inputs) => { - // Findymail's phone finder keys off a LinkedIn URL only. - const linkedin = str(inputs.linkedinUrl) - if (!linkedin) return null - return { linkedin_url: linkedin } - }, - mapOutput: (output) => { - const phone = str(output.phone) - return phone ? { phone } : null - }, - }), - toolProvider({ - id: 'prospeo', - label: 'Prospeo', - toolId: 'prospeo_enrich_person', - buildParams: (inputs) => { - const linkedin = str(inputs.linkedinUrl) - const fullName = str(inputs.fullName) - const companyWebsite = normalizeDomain(inputs.companyDomain) - if (!linkedin && !(fullName && companyWebsite)) return null - return filterUndefined({ - linkedin_url: linkedin || undefined, - full_name: fullName || undefined, - company_website: companyWebsite || undefined, - enrich_mobile: true, - // An unverified mobile becomes a free NO_MATCH instead of a billed hit. - only_verified_mobile: true, - }) - }, - projectFailure: projectProspeoEnrichmentFailure, - mapOutput: (output) => { - const person = output.person as Record | undefined - const mobile = person?.mobile as Record | undefined - const phone = str(mobile?.mobile) - return phone ? { phone } : null - }, - }), - toolProvider({ - id: 'leadmagic', - label: 'LeadMagic', - toolId: 'leadmagic_find_mobile', - buildParams: (inputs) => { - // LeadMagic's mobile finder keys off a LinkedIn URL. - const profileUrl = str(inputs.linkedinUrl) - if (!profileUrl) return null - return { profile_url: profileUrl } - }, - mapOutput: (output) => { - const phone = str(output.mobile_number) - return phone ? { phone } : null - }, - }), - toolProvider({ - id: 'datagma', - label: 'Datagma', - toolId: 'datagma_find_phone', - buildParams: (inputs) => { - // Datagma's phone finder takes the full LinkedIn URL as `username`. - const username = str(inputs.linkedinUrl) - if (!username) return null - return { username } + const companyDomain = normalizeDomain(inputs.companyDomain) + const linkedinUrl = str(inputs.linkedinUrl) + if (!linkedinUrl && !(fullName && companyDomain)) return null + return { enrichmentType: 'phone', fullName, companyDomain, linkedinUrl } }, mapOutput: (output) => { const phone = str(output.phone) diff --git a/apps/sim/enrichments/provider-failures/prospeo.ts b/apps/sim/enrichments/provider-failures/prospeo.ts deleted file mode 100644 index baf9f1db742..00000000000 --- a/apps/sim/enrichments/provider-failures/prospeo.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isRecordLike } from '@sim/utils/object' -import { projectEnrichmentProviderFailure } from '@/enrichments/providers' -import type { - EnrichmentProviderFailure, - EnrichmentProviderFailureProjection, -} from '@/enrichments/types' - -/** Projects Prospeo's documented `NO_MATCH` error as a clean provider miss. */ -export function projectProspeoEnrichmentFailure( - failure: EnrichmentProviderFailure -): EnrichmentProviderFailureProjection { - if ( - isRecordLike(failure.output) && - isRecordLike(failure.output.data) && - failure.output.data.error_code === 'NO_MATCH' - ) { - return { status: 'no_match' } - } - return projectEnrichmentProviderFailure(failure) -} diff --git a/apps/sim/enrichments/providers.ts b/apps/sim/enrichments/providers.ts index 08fb104bb94..f9d4ca4f73e 100644 --- a/apps/sim/enrichments/providers.ts +++ b/apps/sim/enrichments/providers.ts @@ -38,20 +38,9 @@ export function normalizeDomain(value: unknown): string { .replace(/\/.*$/, '') } -/** Returns the first non-empty string in an array (or `undefined`). */ -export function firstNonEmpty(value: unknown): string | undefined { - if (!Array.isArray(value)) return undefined - for (const item of value) { - const s = str(item) - if (s) return s - } - return undefined -} - /** * Splits a full name into first / last for providers whose API requires both - * (e.g. Hunter). Returns `null` when the name has fewer than two parts, so the - * provider falls through to one that accepts a single name string. + * (e.g. FullEnrich). Returns `null` when the name has fewer than two parts. */ export function splitName(fullName: unknown): { firstName: string; lastName: string } | null { const parts = str(fullName).split(/\s+/).filter(Boolean) @@ -63,7 +52,10 @@ export function splitName(fullName: unknown): { firstName: string; lastName: str export function projectEnrichmentProviderFailure( failure: EnrichmentProviderFailure ): EnrichmentProviderFailureProjection { - if (isRecordLike(failure.output) && failure.output.status === 404) { + if ( + isRecordLike(failure.output) && + (failure.output.statusCode ?? failure.output.status) === 404 + ) { return { status: 'no_match' } } return { status: 'error', error: failure.error } diff --git a/apps/sim/enrichments/run.test.ts b/apps/sim/enrichments/run.test.ts index 1f846e2bacb..b886aa1cb43 100644 --- a/apps/sim/enrichments/run.test.ts +++ b/apps/sim/enrichments/run.test.ts @@ -50,35 +50,36 @@ beforeEach(() => { }) describe('runEnrichment cascade detail', () => { - it('records the first match and stops the cascade', async () => { - mockExecuteTool.mockImplementation((toolId: string) => { - if (toolId === 'tool_a') return { success: false, output: { status: 404 } } - if (toolId === 'tool_b') - return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } } - throw new Error('tool_c should never run after a match') - }) - - const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx) - - expect(outcome.result).toEqual({ email: 'j@acme.com' }) - expect(outcome.cost).toBe(0.05) - expect(outcome.error).toBeNull() - expect(outcome.provider).toBe('B') - - expect(outcome.detail.matchedProvider).toBe('b') - expect(outcome.detail.totalCost).toBe(0.05) - // The full cascade is recorded; the provider after the match is `not_run`. - expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c']) - expect(outcome.detail.providers.map((p) => p.status)).toEqual([ - 'no_match', - 'matched', - 'not_run', - ]) - expect(outcome.detail.providers[1]?.cost).toBe(0.05) - expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true) - // The tool is never called for the matched-past provider. - expect(mockExecuteTool).toHaveBeenCalledTimes(2) - }) + it.each(['status', 'statusCode'])( + 'records a %s miss before the first match', + async (statusField) => { + mockExecuteTool.mockImplementation((toolId: string) => { + if (toolId === 'tool_a') return { success: false, output: { [statusField]: 404 } } + if (toolId === 'tool_b') + return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } } + throw new Error('tool_c should never run after a match') + }) + + const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx) + + expect(outcome.result).toEqual({ email: 'j@acme.com' }) + expect(outcome.cost).toBe(0.05) + expect(outcome.error).toBeNull() + expect(outcome.provider).toBe('B') + + expect(outcome.detail.matchedProvider).toBe('b') + expect(outcome.detail.totalCost).toBe(0.05) + expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c']) + expect(outcome.detail.providers.map((p) => p.status)).toEqual([ + 'no_match', + 'matched', + 'not_run', + ]) + expect(outcome.detail.providers[1]?.cost).toBe(0.05) + expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true) + expect(mockExecuteTool).toHaveBeenCalledTimes(2) + } + ) it('marks providers with insufficient inputs as skipped without calling the tool', async () => { mockExecuteTool.mockImplementation(() => ({ diff --git a/apps/sim/enrichments/work-email/work-email.test.ts b/apps/sim/enrichments/work-email/work-email.test.ts deleted file mode 100644 index 66a4433005c..00000000000 --- a/apps/sim/enrichments/work-email/work-email.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { EnrichmentProvider } from '@/enrichments/types' -import { workEmailEnrichment } from '@/enrichments/work-email/work-email' - -function provider(id: string): EnrichmentProvider { - const p = workEmailEnrichment.providers.find((x) => x.id === id) - if (!p) throw new Error(`Provider ${id} not found in work-email cascade`) - return p -} - -const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' } -const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' } - -describe('work-email enrichment cascade', () => { - describe('findymail-linkedin', () => { - const p = provider('findymail-linkedin') - it('keys off the LinkedIn URL and skips without one', () => { - expect(p.toolId).toBe('findymail_find_email_from_linkedin') - expect(p.buildParams(linkedinOnly)).toEqual({ - linkedin_url: 'https://linkedin.com/in/johndoe', - }) - expect(p.buildParams(nameDomain)).toBeNull() - expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' }) - }) - }) - - describe('prospeo (opportunistic)', () => { - const p = provider('prospeo') - it('projects Prospeo NO_MATCH as a clean miss without hiding genuine errors', () => { - expect( - p.projectFailure({ - error: 'NO_MATCH', - output: { status: 400, data: { error: true, error_code: 'NO_MATCH' } }, - }) - ).toEqual({ status: 'no_match' }) - expect( - p.projectFailure({ - error: 'INVALID_API_KEY', - output: { status: 400, data: { error: true, error_code: 'INVALID_API_KEY' } }, - }) - ).toEqual({ status: 'error', error: 'INVALID_API_KEY' }) - }) - }) -}) diff --git a/apps/sim/enrichments/work-email/work-email.ts b/apps/sim/enrichments/work-email/work-email.ts index 7eb271656c9..1f5638aedff 100644 --- a/apps/sim/enrichments/work-email/work-email.ts +++ b/apps/sim/enrichments/work-email/work-email.ts @@ -1,28 +1,7 @@ import { Mail } from '@sim/emcn/icons' -import { filterUndefined, toArray } from '@sim/utils/object' -import { projectProspeoEnrichmentFailure } from '@/enrichments/provider-failures/prospeo' -import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers' +import { normalizeDomain, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' -/** The address Wiza marked `valid`: the primary one, else the first in `emails`. */ -function wizaValidEmail(output: Record): unknown { - if (output.email_status === 'valid') return output.email - const emails = toArray>(output.emails) - return emails.find((e) => e.email_status === 'valid')?.email -} - -/** - * Work Email enrichment. Finds a person's work email from a full name plus any - * available identifiers (company domain, LinkedIn URL) via a provider waterfall: - * deterministic finders first (Hunter, Findymail by name then by LinkedIn), then - * enrichment/reveal providers (Prospeo, Wiza), then Datagma, LeadMagic, - * Dropcontact, and Enrow as additional finders, and People Data Labs last — an - * unverified database record at the highest price per hit. Each provider - * opportunistically uses whatever identifiers the row provides and self-skips - * when it has none usable, so adding more inputs widens coverage. The first - * mailbox-verified email wins; an unverified one (e.g. a catch-all guess) is kept - * only if nothing verified turns up. All providers support hosted keys. - */ export const workEmailEnrichment: EnrichmentConfig = { id: 'work-email', name: 'Work Email', @@ -36,204 +15,21 @@ export const workEmailEnrichment: EnrichmentConfig = { outputs: [{ id: 'email', name: 'email', type: 'string' }], providers: [ toolProvider({ - id: 'hunter', - label: 'Hunter', - toolId: 'hunter_email_finder', - buildParams: (inputs) => { - const name = splitName(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - if (!name || !domain) return null - return { domain, first_name: name.firstName, last_name: name.lastName } - }, - mapOutput: (output) => { - const email = str(output.email) - return email ? { email } : null - }, - // Only a mailbox-verified hit is final; accept_all/unknown guesses are held. - isVerified: (output) => - (output.verification as Record | null)?.status === 'valid', - }), - toolProvider({ - id: 'findymail', - label: 'Findymail', - toolId: 'findymail_find_email_from_name', - buildParams: (inputs) => { - const name = str(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - if (!name || !domain) return null - return { name, domain } - }, - mapOutput: (output) => { - const contact = output.contact as Record | null - const email = str(contact?.email) - return email ? { email } : null - }, - }), - toolProvider({ - id: 'findymail-linkedin', - label: 'Findymail (LinkedIn)', - toolId: 'findymail_find_email_from_linkedin', - buildParams: (inputs) => { - const linkedin = str(inputs.linkedinUrl) - if (!linkedin) return null - return { linkedin_url: linkedin } - }, - mapOutput: (output) => { - const contact = output.contact as Record | null - const email = str(contact?.email) - return email ? { email } : null - }, - }), - toolProvider({ - id: 'prospeo', - label: 'Prospeo', - toolId: 'prospeo_enrich_person', - buildParams: (inputs) => { - const linkedin = str(inputs.linkedinUrl) - const fullName = str(inputs.fullName) - const companyWebsite = normalizeDomain(inputs.companyDomain) - if (!linkedin && !(fullName && companyWebsite)) return null - return filterUndefined({ - linkedin_url: linkedin || undefined, - full_name: fullName || undefined, - company_website: companyWebsite || undefined, - // An unverified email becomes a free NO_MATCH instead of a billed hit. - only_verified_email: true, - }) - }, - projectFailure: projectProspeoEnrichmentFailure, - mapOutput: (output) => { - const person = output.person as Record | undefined - const emailObj = person?.email as Record | undefined - const email = str(emailObj?.email) - return email ? { email } : null - }, - }), - toolProvider({ - id: 'wiza', - label: 'Wiza', - toolId: 'wiza_individual_reveal', - buildParams: (inputs) => { - const linkedin = str(inputs.linkedinUrl) - const fullName = str(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - if (!linkedin && !(fullName && domain)) return null - // 'partial' reveals the email only (2 credits); avoids phone charges. - return filterUndefined({ - profile_url: linkedin || undefined, - full_name: fullName || undefined, - domain: domain || undefined, - enrichment_level: 'partial', - accept_work: true, - accept_personal: false, - }) - }, - // Wiza bills only when a `valid` email comes back, so take that one; a - // `risky`-only result is free and held as a fallback. - mapOutput: (output) => { - const email = str(wizaValidEmail(output)) || str(output.email) - return email ? { email } : null - }, - isVerified: (output) => str(wizaValidEmail(output)) !== '', - }), - toolProvider({ - id: 'datagma', - label: 'Datagma', - toolId: 'datagma_find_email', - buildParams: (inputs) => { - const fullName = str(inputs.fullName) - const company = normalizeDomain(inputs.companyDomain) - if (!fullName || !company) return null - return { fullName, company } - }, - mapOutput: (output) => { - const email = str(output.email) - return email ? { email } : null - }, - }), - toolProvider({ - id: 'leadmagic', - label: 'LeadMagic', - toolId: 'leadmagic_find_email', - buildParams: (inputs) => { - // LeadMagic accepts full_name + domain, so pass the whole name and let it - // split — this keeps single-token (mononym) rows in play. - const fullName = str(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - if (!fullName || !domain) return null - return { full_name: fullName, domain } - }, - mapOutput: (output) => { - const email = str(output.email) - return email ? { email } : null - }, - isVerified: (output) => output.status === 'valid', - }), - toolProvider({ - id: 'dropcontact', - label: 'Dropcontact', - toolId: 'dropcontact_enrich_contact', + id: 'firecrawl', + label: 'Firecrawl', + toolId: 'firecrawl_enrich', buildParams: (inputs) => { const fullName = str(inputs.fullName) - const website = normalizeDomain(inputs.companyDomain) - const linkedin = str(inputs.linkedinUrl) - if (!fullName || (!website && !linkedin)) return null - return filterUndefined({ - full_name: fullName, - website: website || undefined, - linkedin: linkedin || undefined, - }) - }, - mapOutput: (output) => { - const email = str(output.email) - return email ? { email } : null - }, - // The live API labels a verified work address `nominatif@pro`; its docs say `nominative@pro`. - isVerified: (output) => - output.qualification === 'nominatif@pro' || output.qualification === 'nominative@pro', - }), - toolProvider({ - id: 'enrow', - label: 'Enrow', - toolId: 'enrow_find_email', - buildParams: (inputs) => { - const fullname = str(inputs.fullName) - const company_domain = normalizeDomain(inputs.companyDomain) - if (!fullname || !company_domain) return null - return { fullname, company_domain } + const companyDomain = normalizeDomain(inputs.companyDomain) + const linkedinUrl = str(inputs.linkedinUrl) + if (!linkedinUrl && !(fullName && companyDomain)) return null + return { enrichmentType: 'work_email', fullName, companyDomain, linkedinUrl } }, mapOutput: (output) => { - // Enrow qualifies each found email valid/invalid; only accept verified-valid - // results so the cell isn't filled with an address Enrow itself rejected - // (and which hosted billing correctly charges zero for). const email = str(output.email) - const qualification = str(output.qualification).toLowerCase() - return email && qualification === 'valid' ? { email } : null - }, - }), - toolProvider({ - id: 'pdl', - label: 'People Data Labs', - toolId: 'pdl_person_enrich', - buildParams: (inputs) => { - const name = str(inputs.fullName) - if (!name) return null - // `required` makes PDL 404 (free) when the profile has no work email, - // instead of charging a credit for a match we'd discard as a no-match. - return filterUndefined({ - name, - company: normalizeDomain(inputs.companyDomain) || undefined, - min_likelihood: 6, - required: 'work_email', - }) - }, - mapOutput: (output) => { - const person = output.person as Record | undefined - const email = str(person?.work_email) return email ? { email } : null }, - // Database record, never mailbox-checked; runs only while nothing is held. - isVerified: false, + isVerified: (output) => output.emailVerified === true, }), ], } diff --git a/apps/sim/executor/handlers/credential/handler.test.ts b/apps/sim/executor/handlers/credential/handler.test.ts index 73de1816055..f2d08647d8f 100644 --- a/apps/sim/executor/handlers/credential/handler.test.ts +++ b/apps/sim/executor/handlers/credential/handler.test.ts @@ -66,23 +66,36 @@ describe('Credential organization operations', () => { workspaceId: 'child-workspace', email: account.email, credentialProviderIds: ['google-email'], - limit: 2, + limit: 1, cursor: undefined, }, }) }) - it.each([0, 2])('rejects an ambiguous or missing account (%i matches)', async (count) => { + it('rejects a missing account', async () => { + mocks.oauth.mockResolvedValue({ credentials: [], hasMore: false }) + await expect( + handler.execute(ctx, block, { + operation: 'find_organization_account', + email: account.email, + organizationProvider: 'google-email', + }) + ).rejects.toThrow('No organization account') + }) + it('keeps returning the first connected account when a person adds another', async () => { mocks.oauth.mockResolvedValue({ - credentials: Array.from({ length: count }, () => account), + credentials: [ + { ...account, credentialId: 'first', accountEmail: 'first@example.com' }, + { ...account, credentialId: 'added', accountEmail: account.email }, + ], hasMore: false, }) - await expect( - handler.execute(ctx, block, { + expect( + await handler.execute(ctx, block, { operation: 'find_organization_account', email: account.email, organizationProvider: 'google-email', }) - ).rejects.toThrow('Expected exactly one') + ).toMatchObject({ credentialId: 'first' }) }) it('returns the person’s MCP credential separately from the shared server', async () => { const connection = { @@ -103,7 +116,7 @@ describe('Credential organization operations', () => { input: expect.objectContaining({ connectorId: 'fireflies', email: account.email, - limit: 2, + limit: 1, }), }) ) diff --git a/apps/sim/executor/handlers/credential/handler.ts b/apps/sim/executor/handlers/credential/handler.ts index ac7b0f888a9..4bbe708f0c7 100644 --- a/apps/sim/executor/handlers/credential/handler.ts +++ b/apps/sim/executor/handlers/credential/handler.ts @@ -111,18 +111,19 @@ export class CredentialBlockHandler implements BlockHandler { credentialProviderIds: find ? [requireString(inputs.organizationProvider, 'Provider')] : parseStringList(inputs.organizationProviders, 'Providers'), - limit: find ? 2 : parseLimit(inputs.limit), + limit: find ? 1 : parseLimit(inputs.limit), cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'), }, }) if (!find) { return { ...result, emails: result.credentials.map((account) => account.accountEmail) } } - if (result.credentials.length !== 1 || result.hasMore) + const [first] = result.credentials + if (!first) throw new Error( - `Expected exactly one organization account; found ${result.credentials.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.` + 'No organization account found. Check the email, provider, and connection status.' ) - return omit(result.credentials[0]!, ['accountEmail']) + return omit(first, ['accountEmail']) } case 'find_organization_mcp_connection': case 'list_organization_mcp_connections': { @@ -137,16 +138,17 @@ export class CredentialBlockHandler implements BlockHandler { connectorId: find ? requireString(inputs.mcpProvider, 'MCP provider') : parseOptionalString(inputs.mcpProvider, 'MCP provider'), - limit: find ? 2 : parseLimit(inputs.limit), + limit: find ? 1 : parseLimit(inputs.limit), cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'), }, }) if (!find) return result - if (result.mcpConnections.length !== 1 || result.hasMore) + const [first] = result.mcpConnections + if (!first) throw new Error( - `Expected exactly one organization MCP connection; found ${result.mcpConnections.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.` + 'No organization MCP connection found. Check the email, provider, and connection status.' ) - return result.mcpConnections[0]! + return first } default: throw new Error(`Unsupported Credential operation: ${operation}`) diff --git a/apps/sim/executor/handlers/generic/handler.test.ts b/apps/sim/executor/handlers/generic/handler.test.ts index a78fdf237d0..2a88fe9339e 100644 --- a/apps/sim/executor/handlers/generic/handler.test.ts +++ b/apps/sim/executor/handlers/generic/handler.test.ts @@ -3,6 +3,7 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { executeBufferTool } from '@/lib/internal/buffer/execute-tool' import { BufferBlock } from '@/blocks/blocks/buffer' +import { FlowBlock } from '@/blocks/blocks/flow' import { HarmonicBlock } from '@/blocks/blocks/harmonic' import { McpBlock } from '@/blocks/blocks/mcp' import { getBlock } from '@/blocks/index' @@ -15,6 +16,18 @@ import bufferPostFixture from '@/tools/buffer/__fixtures__/post.json' import { bufferCreatePostTool } from '@/tools/buffer/create_post' import { bufferEditPostTool } from '@/tools/buffer/edit_post' import type { BufferCreatePostParams, BufferEditPostParams } from '@/tools/buffer/types' +import { flowCreateBranchTool } from '@/tools/flow/create-branch' +import { flowListCommentsTool } from '@/tools/flow/list-comments' +import { flowListEntitiesTool } from '@/tools/flow/list-entities' +import { flowSetValueTool } from '@/tools/flow/set-value' +import type { + FlowCreateBranchParams, + FlowListCommentsParams, + FlowListEntitiesParams, + FlowSetValueParams, + FlowUpdateCommentParams, +} from '@/tools/flow/types' +import { flowUpdateCommentTool } from '@/tools/flow/update-comment' import { mcpRunOperationTool } from '@/tools/mcp/run-operation' import type { ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' @@ -82,6 +95,177 @@ describe('GenericBlockHandler', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { customResult: 'OK' } }) }) + describe('Flow field values', () => { + const writes: unknown[] = [] + + beforeEach(async () => { + writes.length = 0 + mockGetBlock.mockReturnValue(FlowBlock) + mockGetTool.mockReturnValue(flowSetValueTool) + mockBlock.metadata = { id: 'flow', name: 'Flow' } + mockBlock.config = { tool: 'flow_set_value', params: {} } + const { validateRequiredParametersAfterMerge } = + await vi.importActual('@/tools/utils') + mockExecuteTool.mockImplementation(async (toolId: string, params: FlowSetValueParams) => { + validateRequiredParametersAfterMerge(toolId, flowSetValueTool, params) + writes.push(await Response.json(flowSetValueTool.request.body?.(params)).json()) + return { success: true, output: {} } + }) + }) + + const values = ['123', 'true', '{"key":1}', '', null, 123, false, [1, '2'], { key: 1 }] + it.each(values.map((value) => ({ value, encoded: JSON.stringify(value) })))( + 'preserves the JSON value $encoded on the provider wire', + async ({ value, encoded }) => { + await handler.execute(mockContext, mockBlock, { + operation: 'set_value', + projectId: 'project', + entityId: 'entity', + fieldKey: 'name', + value: encoded, + }) + expect(writes).toEqual([{ key: 'name', value }]) + } + ) + + it.each([undefined, '', '{"broken":', 'unquoted text'])( + 'rejects omitted or malformed JSON before sending a field write (%s)', + async (value) => { + await expect( + handler.execute(mockContext, mockBlock, { + operation: 'set_value', + projectId: 'project', + entityId: 'entity', + fieldKey: 'name', + value, + }) + ).rejects.toThrow() + expect(writes).toEqual([]) + } + ) + + it('rejects a whitespace-only field key before sending a write', async () => { + await expect( + handler.execute(mockContext, mockBlock, { + operation: 'set_value', + projectId: 'project', + entityId: 'entity', + fieldKey: ' \t ', + value: '"updated"', + }) + ).rejects.toThrow('Field key is required') + expect(writes).toEqual([]) + }) + }) + + describe('Flow list limits', () => { + const requests: string[] = [] + + beforeEach(() => { + requests.length = 0 + mockGetBlock.mockReturnValue(FlowBlock) + mockGetTool.mockReturnValue(flowListEntitiesTool) + mockExecuteTool.mockImplementation( + async (_toolId: string, params: FlowListEntitiesParams) => { + const url = flowListEntitiesTool.request.url + requests.push(typeof url === 'function' ? url(params) : url) + return { success: true, output: {} } + } + ) + mockBlock.metadata = { id: 'flow', name: 'Flow' } + mockBlock.config = { tool: 'flow_list_entities', params: {} } + }) + + it.each([ + { limit: null, expected: '50' }, + { limit: '', expected: '50' }, + { limit: ' ', expected: '50' }, + { limit: '25', expected: '25' }, + ])('sends page size $expected when the Limit input is $limit', async ({ limit, expected }) => { + await handler.execute(mockContext, mockBlock, { + operation: 'list_entities', + domain: 'https://api.example.com', + projectId: 'project', + limit, + }) + expect(requests.map((url) => new URL(url).searchParams.get('limit'))).toEqual([expected]) + }) + + it('rejects an explicit zero instead of silently replacing it with the default', async () => { + await expect( + handler.execute(mockContext, mockBlock, { + operation: 'list_entities', + domain: 'https://api.example.com', + projectId: 'project', + limit: '0', + }) + ).rejects.toThrow('Limit must be an integer between 1 and 1000') + expect(requests).toEqual([]) + }) + }) + + it.each([ + { selection: 'untouched', value: null, expected: undefined }, + { selection: 'cleared', value: '', expected: undefined }, + { selection: 'No', value: 'false', expected: false }, + { selection: 'Yes', value: true, expected: true }, + ])( + 'sends optional Flow booleans correctly for $selection dropdowns', + async ({ value, expected }) => { + const requests: { includeResolved: string | null; body: unknown }[] = [] + mockGetBlock.mockReturnValue(FlowBlock) + mockBlock.metadata = { id: 'flow', name: 'Flow' } + + for (const tool of [flowListCommentsTool, flowUpdateCommentTool, flowCreateBranchTool]) { + mockGetTool.mockReturnValue(tool) + mockBlock.config = { tool: tool.id, params: {} } + mockExecuteTool.mockImplementation( + async ( + _toolId: string, + params: FlowListCommentsParams & FlowUpdateCommentParams & FlowCreateBranchParams + ) => { + const url = tool.request.url + requests.push({ + includeResolved: new URL( + typeof url === 'function' ? url(params) : url + ).searchParams.get('include_resolved'), + body: await Response.json(tool.request.body?.(params) ?? null).json(), + }) + return { success: true, output: {} } + } + ) + await handler.execute(mockContext, mockBlock, { + operation: tool.id.replace('flow_', ''), + domain: 'https://api.example.com', + projectId: 'project', + entityId: 'entity', + commentId: 'comment', + text: 'Updated comment', + branchName: 'Pilot', + includeResolved: value, + resolved: value, + isVariant: value, + }) + } + + expect(requests).toEqual([ + { includeResolved: expected === undefined ? null : String(expected), body: null }, + { + includeResolved: null, + body: + expected === undefined + ? { text: 'Updated comment' } + : { text: 'Updated comment', resolved: expected }, + }, + { + includeResolved: null, + body: + expected === undefined ? { name: 'Pilot' } : { name: 'Pilot', is_variant: expected }, + }, + ]) + } + ) + it.each([ { selection: 'keep-current scheduling', diff --git a/apps/sim/executor/handlers/human-in-the-loop/handler.ts b/apps/sim/executor/handlers/human-in-the-loop/handler.ts index 1ac7e5d5a8f..c24812b6377 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/handler.ts @@ -423,6 +423,9 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { ).filter(isCanonicalPair) const { paramsTransform } = buildBlockToolParamsTransform({ + operation: + toolConfig.operation ?? + (typeof storedParams.operation === 'string' ? storedParams.operation : undefined), blockSubBlocks: subBlocks, blockParamsFn: notificationBlock?.tools?.config?.params, blockInputDefs: notificationBlock?.inputs, diff --git a/apps/sim/executor/handlers/trigger/handler.ts b/apps/sim/executor/handlers/trigger/handler.ts index f31aed7371a..3775d6fd6d1 100644 --- a/apps/sim/executor/handlers/trigger/handler.ts +++ b/apps/sim/executor/handlers/trigger/handler.ts @@ -15,7 +15,7 @@ export class TriggerBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { - logger.info(`Executing trigger block: ${block.id} (Type: ${block.metadata?.id})`) + logger.debug(`Executing trigger block: ${block.id} (Type: ${block.metadata?.id})`) if (block.metadata?.id === BlockType.STARTER) { return this.executeStarterBlock(ctx, block, inputs) diff --git a/apps/sim/executor/utils/credential-token.ts b/apps/sim/executor/utils/credential-token.ts index 7199e1bc41a..e68542abace 100644 --- a/apps/sim/executor/utils/credential-token.ts +++ b/apps/sim/executor/utils/credential-token.ts @@ -132,7 +132,8 @@ export async function resolveExecutorCredentialToken( if (result.code === OAUTH_CREDENTIAL_REVOKED) { throw new CredentialRevokedError(message) } - logger.error(`[${requestId}] Credential token resolution failed`, { + logger.error('Credential token resolution failed', { + requestId, status: result.status, credentialId, code: result.code, diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index bb9289d473a..0fb22b53188 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -34,7 +34,7 @@ export async function resolveVertexCredential({ }: ResolveVertexCredentialParams): Promise { const requestId = `${callerLabel}-${Date.now()}` - logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + logger.info(`Resolving Vertex AI credential: ${credentialId}`, { requestId }) if (!actingUserId) { throw new Error('Vertex AI credential use requires an authenticated user') @@ -46,7 +46,8 @@ export async function resolveVertexCredential({ throw new Error(`Vertex AI credential not found: ${credentialId}`) } if (workspaceId && cred.workspaceId !== workspaceId) { - logger.warn(`[${requestId}] Vertex AI credential belongs to a different workspace`, { + logger.warn('Vertex AI credential belongs to a different workspace', { + requestId, credentialId, executingWorkspaceId: workspaceId, }) @@ -69,7 +70,7 @@ export async function resolveVertexCredential({ workflowId, executionId, }) - logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`) + logger.info('Successfully resolved Vertex AI service account credential', { requestId }) return accessToken } @@ -90,6 +91,6 @@ export async function resolveVertexCredential({ throw new Error('Failed to get Vertex AI access token') } - logger.info(`[${requestId}] Successfully resolved Vertex AI credential`) + logger.info('Successfully resolved Vertex AI credential', { requestId }) return accessToken } diff --git a/apps/sim/hooks/queries/aws-marketplace.test.tsx b/apps/sim/hooks/queries/aws-marketplace.test.tsx new file mode 100644 index 00000000000..68ab8b89a30 --- /dev/null +++ b/apps/sim/hooks/queries/aws-marketplace.test.tsx @@ -0,0 +1,74 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react' +import { + apiClientRequestMock, + apiClientRequestMockFns, +} from '@sim/testing/mocks/api-client-request.mock' +import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/api/client/request', () => apiClientRequestMock) +vi.mock('@/lib/auth/client', () => authClientMock) + +import { useAwsMarketplaceBilling } from '@/hooks/queries/aws-marketplace' + +const mockRequestJson = apiClientRequestMockFns.mockRequestJson + +let root: Root | null = null +let billing: ReturnType | undefined + +function Probe() { + billing = useAwsMarketplaceBilling('org-1') + return null +} + +function render() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + + + ) + }) +} + +async function settled(assertion: () => void) { + await act(async () => { + await vi.waitFor(assertion, { interval: 1, timeout: 2_000 }) + }) +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + billing = undefined + document.body.replaceChildren() +}) + +describe('useAwsMarketplaceBilling', () => { + it('stays unresolved when the license lookup fails, so plan changes stay gated', async () => { + mockRequestJson.mockRejectedValueOnce(new Error('lookup failed')) + render() + + await settled(() => expect(billing?.isError).toBe(true)) + expect(billing?.isResolved).toBe(false) + expect(billing?.billedThroughMarketplace).toBe(false) + }) + + it('resolves to not Marketplace-billed when the scope holds no license', async () => { + mockRequestJson.mockResolvedValueOnce({ licenses: [] }) + render() + + await settled(() => expect(billing?.isResolved).toBe(true)) + expect(billing?.billedThroughMarketplace).toBe(false) + expect(billing?.isError).toBe(false) + }) +}) diff --git a/apps/sim/hooks/queries/aws-marketplace.ts b/apps/sim/hooks/queries/aws-marketplace.ts new file mode 100644 index 00000000000..ef5fa54fe05 --- /dev/null +++ b/apps/sim/hooks/queries/aws-marketplace.ts @@ -0,0 +1,111 @@ +import { useCallback } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + getAwsMarketplaceClaimContract, + linkAwsMarketplaceLicenseContract, + listAccountAwsMarketplaceLicensesContract, + listOrganizationAwsMarketplaceLicensesContract, + registerAwsMarketplaceLicenseToAccountContract, + saveAwsMarketplaceClaimContactContract, +} from '@/lib/api/contracts/aws-marketplace' +import { useSession } from '@/lib/auth/client' + +const AWS_MARKETPLACE_CLAIM_STALE_TIME = 30 * 1000 +const AWS_MARKETPLACE_LICENSES_STALE_TIME = 60 * 1000 + +const awsMarketplaceKeys = { + all: ['aws-marketplace'] as const, + claims: () => [...awsMarketplaceKeys.all, 'claim'] as const, + /** The claim rides in an httpOnly cookie; the viewer scopes which organizations it lists. */ + claim: (viewerId: string) => [...awsMarketplaceKeys.claims(), viewerId] as const, + licenses: () => [...awsMarketplaceKeys.all, 'licenses'] as const, + /** An organization's licenses, or one viewer's own account's: the session scopes that read. */ + licenseList: (scope: { organizationId: string } | { viewerId: string }) => + [...awsMarketplaceKeys.licenses(), scope] as const, +} + +export function useAwsMarketplaceClaim(viewerId: string | null) { + return useQuery({ + queryKey: awsMarketplaceKeys.claim(viewerId ?? ''), + queryFn: ({ signal }) => requestJson(getAwsMarketplaceClaimContract, { signal }), + enabled: Boolean(viewerId), + staleTime: AWS_MARKETPLACE_CLAIM_STALE_TIME, + retry: false, + }) +} + +/** Taking a license spends the claim and changes what billing shows, so both are read again. */ +function useInvalidateAwsMarketplace() { + const queryClient = useQueryClient() + return () => queryClient.invalidateQueries({ queryKey: awsMarketplaceKeys.all }) +} + +export function useLinkAwsMarketplaceLicense() { + const invalidate = useInvalidateAwsMarketplace() + return useMutation({ + mutationFn: (body: ContractBodyInput) => + requestJson(linkAwsMarketplaceLicenseContract, { body }), + onSuccess: invalidate, + }) +} + +export function useRegisterAwsMarketplaceLicenseToAccount() { + const invalidate = useInvalidateAwsMarketplace() + return useMutation({ + mutationFn: (body: ContractBodyInput) => + requestJson(registerAwsMarketplaceLicenseToAccountContract, { body }), + onSuccess: invalidate, + }) +} + +export function useSaveAwsMarketplaceClaimContact() { + const invalidate = useInvalidateAwsMarketplace() + return useMutation({ + mutationFn: (body: ContractBodyInput) => + requestJson(saveAwsMarketplaceClaimContactContract, { body }), + onSuccess: invalidate, + }) +} + +/** + * Whether AWS Marketplace bills this scope: the organization, or with null the viewer's account. + * Until `isResolved`, how the scope is billed is unknown, and an unknown state gates plan changes + * the same as Marketplace billing does: a failed lookup must not open Stripe checkout to a + * customer AWS bills. `retry` reads it again. + */ +export function useAwsMarketplaceBilling(organizationId: string | null) { + const licenses = useAwsMarketplaceLicenses(organizationId) + const { refetch } = licenses + const retry = useCallback(() => { + void refetch() + }, [refetch]) + return { + isResolved: licenses.data !== undefined, + isError: licenses.isError, + isRetrying: licenses.isFetching, + retry, + billedThroughMarketplace: + licenses.data?.some((license) => license.status !== 'inactive') ?? false, + } +} + +/** The Marketplace subscriptions an organization holds, or with null the viewer's own account. */ +export function useAwsMarketplaceLicenses(organizationId: string | null) { + const { data: session } = useSession() + const viewerId = session?.user?.id ?? '' + return useQuery({ + queryKey: awsMarketplaceKeys.licenseList(organizationId ? { organizationId } : { viewerId }), + enabled: Boolean(organizationId || viewerId), + queryFn: ({ signal }) => + organizationId + ? requestJson(listOrganizationAwsMarketplaceLicensesContract, { + params: { id: organizationId }, + signal, + }) + : requestJson(listAccountAwsMarketplaceLicensesContract, { signal }), + staleTime: AWS_MARKETPLACE_LICENSES_STALE_TIME, + select: (data) => data.licenses, + }) +} diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index a8b017ff736..414b5b44ab6 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -54,9 +54,9 @@ import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-pop import { isDesktopApp } from '@/lib/desktop' import { connectDesktopSource } from '@/lib/desktop/source-connect' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' +import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys' import { searchIntegrationKeys } from '@/hooks/queries/utils/search-integration-keys' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 9fba5796983..70250e6288e 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -350,7 +350,8 @@ export function useMothershipChats( /** Private organization conversations use their own cache scope and current membership. */ export function useOrganizationMothershipChats( organizationId: string, - scope: MothershipChatScope = 'active' + scope: MothershipChatScope = 'active', + options?: { enabled?: boolean } ) { return useQuery({ queryKey: mothershipChatKeys.organizationList(organizationId, scope), @@ -361,6 +362,7 @@ export function useOrganizationMothershipChats( }) return data.data.map(mapChat) }, + enabled: options?.enabled ?? true, staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index ccbb8ef7ec6..3c7261aa676 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -16,15 +16,29 @@ vi.mock('next/navigation', () => nextNavigationMock) import { ApiClientError } from '@/lib/api/client/errors' import { listOrganizationAccountPeopleContract } from '@/lib/api/contracts/organization-accounts' +import { resourceScopeKey } from '@/lib/core/resource-scope' import { useOrganizationAccountPeople } from '@/hooks/queries/organization-accounts' +import { + useRemovePersonalSearchCredential, + useSavePersonalSearchCredential, +} from '@/hooks/queries/personal-search-integrations' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const mockRequestJson = apiClientRequestMockFns.mockRequestJson -describe('organization people search pagination', () => { +describe('organization accounts', () => { let root: Root let container: HTMLDivElement let client: QueryClient let result: ReturnType + let removal: ReturnType + let save: ReturnType + + function CredentialMutationProbe() { + removal = useRemovePersonalSearchCredential() + save = useSavePersonalSearchCredential() + return null + } function Probe({ search, @@ -130,6 +144,51 @@ describe('organization people search pagination', () => { expect(result.data?.pages).toHaveLength(1) }) + it.each(['remove', 'reconnect'] as const)( + 'clears cached Search content after %s while preserving another organization’s results', + async (action) => { + const search = knowledgeKeys.search( + resourceScopeKey({ kind: 'organization', organizationId: 'org-1' }), + 'private content' + ) + const otherSearch = knowledgeKeys.search( + resourceScopeKey({ kind: 'organization', organizationId: 'org-2' }), + 'private content' + ) + const document = knowledgeKeys.document('knowledge-1', 'document-1') + client.setQueryData(search, [{ content: 'previous credential content' }]) + client.setQueryData(otherSearch, [{ content: 'other organization content' }]) + client.setQueryData(document, { content: 'previous credential document' }) + mockRequestJson.mockResolvedValueOnce( + action === 'remove' ? { success: true } : { credentialId: 'credential-1' } + ) + + await act(async () => + root.render( + + + + ) + ) + await act(async () => { + const body = { organizationId: 'org-1', credentialId: 'credential-1' } + if (action === 'remove') await removal.mutateAsync(body) + else + await save.mutateAsync({ + ...body, + providerId: 'flow-service-account', + apiToken: 'rotated-token', + domain: 'https://api.flowengineering.com', + providerWorkspaceId: 'workspace-1', + }) + }) + + expect(client.getQueryData(search)).toBeUndefined() + expect(client.getQueryData(document)).toBeUndefined() + expect(client.getQueryData(otherSearch)).toEqual([{ content: 'other organization content' }]) + } + ) + it.each([400, 401, 403, 404, 409, 422])( 'does not retry a non-retryable %s response', async (status) => { diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index b514f67f515..a94b62779cb 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' import { generateId } from '@sim/utils/id' import { isServer, + queryOptions, useInfiniteQuery, useMutation, useQuery, @@ -48,13 +49,18 @@ import { isDesktopApp } from '@/lib/desktop' import { connectDesktopSource } from '@/lib/desktop/source-connect' import { assistantActionKeys } from '@/hooks/queries/assistant-actions' import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' +import { personalSearchIntegrationKeys } from '@/hooks/queries/personal-search-integrations' +import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' +import { + ORGANIZATION_ACCOUNTS_STALE_TIME, + organizationAccountsKeys, +} from '@/hooks/queries/utils/organization-accounts-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' +import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' -export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 - function useAccountConnectionMutation( requestFor: ( variables: Variables @@ -99,7 +105,7 @@ function useAccountConnectionMutation( ) } }, - onSettled: () => refreshAccounts(client), + onSettled: () => refreshConnectedAccounts(client), }) } @@ -146,6 +152,7 @@ export function useDisconnectPersonalOrganizationAccount() { await Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ @@ -158,30 +165,9 @@ export function useDisconnectPersonalOrganizationAccount() { }) } -export const organizationAccountsKeys = { - all: ['organization-accounts'] as const, - connection: () => [...organizationAccountsKeys.all, 'connection'] as const, - personal: () => [...organizationAccountsKeys.all, 'personal'] as const, - workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, - workspace: (workspaceId?: string) => - [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, - access: (id?: string) => [...organizationAccountsKeys.detail(id), 'access'] as const, - people: (id?: string) => [...organizationAccountsKeys.detail(id), 'people'] as const, - peopleList: (id: string, search?: string, optionId?: string) => - [ - ...organizationAccountsKeys.people(id), - { search: search ?? '', optionId: optionId ?? '' }, - ] as const, - databricks: (id?: string) => [...organizationAccountsKeys.detail(id), 'databricks'] as const, - details: () => [...organizationAccountsKeys.all, 'detail'] as const, - detail: (organizationId?: string) => - [...organizationAccountsKeys.details(), organizationId ?? ''] as const, -} - -export function useOrganizationAccounts(organizationId?: string) { - return useQuery({ +function organizationAccountsQueryOptions(organizationId?: string) { + return queryOptions({ queryKey: organizationAccountsKeys.detail(organizationId), - enabled: Boolean(organizationId), staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, queryFn: ({ signal }) => { if (!organizationId) throw new Error('Organization is required') @@ -193,6 +179,13 @@ export function useOrganizationAccounts(organizationId?: string) { }) } +export function useOrganizationAccounts(organizationId?: string) { + return useQuery({ + ...organizationAccountsQueryOptions(organizationId), + enabled: Boolean(organizationId), + }) +} + export function useEnsureOrganizationAccounts() { const queryClient = useQueryClient() return useMutation({ @@ -273,11 +266,15 @@ export function useUpdateOrganizationAccounts() { }) } -async function refreshAccounts(client: ReturnType) { +/** Everything a newly connected account changes, including what Search can now reach. */ +export async function refreshConnectedAccounts(client: ReturnType) { await Promise.all([ client.invalidateQueries({ queryKey: organizationAccountsKeys.all }), client.invalidateQueries({ queryKey: assistantActionKeys.permissions() }), client.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), + client.invalidateQueries({ queryKey: searchSourceKeys.lists() }), + client.invalidateQueries({ queryKey: knowledgeKeys.searches() }), client.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), invalidateSelectorQueries(client), ]) diff --git a/apps/sim/hooks/queries/organization-secrets.ts b/apps/sim/hooks/queries/organization-secrets.ts index 1c79d268696..a8a67b4d33b 100644 --- a/apps/sim/hooks/queries/organization-secrets.ts +++ b/apps/sim/hooks/queries/organization-secrets.ts @@ -10,18 +10,10 @@ import { saveOrganizationSecretsContract, } from '@/lib/api/contracts/organization-secrets' import type { SecretSourceMode } from '@/lib/organization-secrets/validation' - -export const ORGANIZATION_SECRET_STALE_TIME = 60 * 1000 -export const organizationSecretKeys = { - all: ['organization-secrets'] as const, - sources: () => [...organizationSecretKeys.all, 'source'] as const, - source: (organizationId: string) => - [...organizationSecretKeys.sources(), organizationId] as const, - environments: (organizationId: string) => - [...organizationSecretKeys.all, 'environment', organizationId] as const, - environment: (organizationId: string, mode: SecretSourceMode) => - [...organizationSecretKeys.environments(organizationId), mode] as const, -} +import { + ORGANIZATION_SECRET_STALE_TIME, + organizationSecretKeys, +} from '@/hooks/queries/utils/organization-secret-keys' export function useOrganizationSecretSource(organizationId: string) { return useQuery({ diff --git a/apps/sim/hooks/queries/people-sync.ts b/apps/sim/hooks/queries/people-sync.ts index f5e49767251..407256f3a7c 100644 --- a/apps/sim/hooks/queries/people-sync.ts +++ b/apps/sim/hooks/queries/people-sync.ts @@ -1,5 +1,11 @@ import { useRef } from 'react' -import { type QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + type QueryClient, + useIsMutating, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' @@ -19,6 +25,8 @@ const IDLE_SYNC_POLL_MS = 30_000 const syncKeys = { all: ['people-directory-sync'] as const, organization: (organizationId: string) => [...syncKeys.all, organizationId] as const, + actions: (organizationId: string) => + [...syncKeys.organization(organizationId), 'action'] as const, } type SyncResult = ContractJsonResponse @@ -34,9 +42,10 @@ function refreshPublishedPeople(client: QueryClient, organizationId: string, res } } -export function usePeopleSyncStatus(organizationId: string) { +export function usePeopleSyncStatus(organizationId: string, enabled = true) { const client = useQueryClient() return useQuery({ + enabled, queryKey: syncKeys.organization(organizationId), queryFn: async ({ signal }) => { const result = await requestJson(getPeopleSyncContract, { @@ -64,8 +73,10 @@ export function usePeopleSyncActions(organizationId: string) { const client = useQueryClient() // Only the action name may enter the mutation cache or its devtools. const nextConfig = useRef(null) - const busy = useRef(false) + const mutationKey = syncKeys.actions(organizationId) + const pending = useIsMutating({ mutationKey }) > 0 const mutation = useMutation({ + mutationKey, gcTime: 0, retry: false, networkMode: 'always', @@ -80,35 +91,40 @@ export function usePeopleSyncActions(organizationId: string) { if (!body) throw new Error('Choose a directory connection') return requestJson(configurePeopleSyncContract, { params, body }) }, - onSuccess: async (result) => { + onSuccess: async (result, action) => { const queryKey = syncKeys.organization(organizationId) await client.cancelQueries({ queryKey }) - const refreshed = refreshPublishedPeople(client, organizationId, result) + // A sync can publish while the disconnect is in flight, so its last-success time can't say + // whether the cached people still exist. + const refreshed = + action === 'disconnect' + ? client.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) }) + : refreshPublishedPeople(client, organizationId, result) client.setQueryData(queryKey, result) await refreshed }, onError: () => client.invalidateQueries({ queryKey: syncKeys.organization(organizationId) }), onSettled: () => { - busy.current = false nextConfig.current = null }, }) function submit( action: 'configure' | 'run' | 'disconnect', config?: PeopleSyncConfig, - onSuccess?: () => void + onSuccess?: () => void, + onError?: (error: Error) => void ) { - if (busy.current) return - busy.current = true + if (client.isMutating({ mutationKey }) > 0) return nextConfig.current = config ?? null - mutation.mutate(action, { onSuccess }) + mutation.mutate(action, { onSuccess: () => onSuccess?.(), onError }) } return { configure: (config: PeopleSyncConfig, onSuccess: () => void) => submit('configure', config, onSuccess), run: () => submit('run'), - disconnect: (onSuccess: () => void) => submit('disconnect', undefined, onSuccess), - pending: mutation.isPending, + disconnect: (onSuccess: () => void, onError?: (error: Error) => void) => + submit('disconnect', undefined, onSuccess, onError), + pending, action: mutation.isPending ? mutation.variables : null, error: mutation.error, reset: mutation.reset, diff --git a/apps/sim/hooks/queries/personal-search-integrations.ts b/apps/sim/hooks/queries/personal-search-integrations.ts index 1f718b464b6..c365b4bb48c 100644 --- a/apps/sim/hooks/queries/personal-search-integrations.ts +++ b/apps/sim/hooks/queries/personal-search-integrations.ts @@ -8,9 +8,16 @@ import { listPersonalSearchIntegrationsContract, type PersonalSearchIntegrationsQuery, } from '@/lib/api/contracts/knowledge/personal-integrations' +import { + type RemoveSearchPersonalCredentialBody, + removeSearchPersonalCredentialContract, + type SaveSearchPersonalCredentialBody, + saveSearchPersonalCredentialContract, +} from '@/lib/api/contracts/search-personal-credentials' import { isDesktopApp } from '@/lib/desktop' import { connectDesktopSource } from '@/lib/desktop/source-connect' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys' +import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' export const PERSONAL_SEARCH_INTEGRATIONS_STALE_TIME = 15_000 @@ -62,3 +69,36 @@ export function useConnectPersonalSearchIntegration() { ]), }) } + +/** Saves a member-owned provider credential and clears Search content fetched with prior access. */ +export function useSavePersonalSearchCredential() { + const client = useQueryClient() + return useMutation({ + mutationFn: (body: SaveSearchPersonalCredentialBody) => + requestJson(saveSearchPersonalCredentialContract, { body }), + onSuccess: (_result, body) => + Promise.all([ + client.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(body.organizationId), + }), + client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), + resetOrganizationSearchAccess(client, body.organizationId), + ]), + }) +} + +export function useRemovePersonalSearchCredential() { + const client = useQueryClient() + return useMutation({ + mutationFn: (body: RemoveSearchPersonalCredentialBody) => + requestJson(removeSearchPersonalCredentialContract, { body }), + onSuccess: (_result, body) => + Promise.all([ + client.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(body.organizationId), + }), + client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), + resetOrganizationSearchAccess(client, body.organizationId), + ]), + }) +} diff --git a/apps/sim/hooks/queries/search-history.ts b/apps/sim/hooks/queries/search-history.ts index 0b20a7f5bc3..a8fabc17494 100644 --- a/apps/sim/hooks/queries/search-history.ts +++ b/apps/sim/hooks/queries/search-history.ts @@ -6,23 +6,17 @@ import { type RecordSearchHistoryBody, recordSearchHistoryContract, } from '@/lib/api/contracts/knowledge/search-history' -import { useSession } from '@/lib/auth/client' - -export const SEARCH_HISTORY_STALE_TIME = 30_000 -export const searchHistoryKeys = { - all: ['search-history'] as const, - lists: () => [...searchHistoryKeys.all, 'list'] as const, - list: (organizationId: string | undefined, userId: string | undefined) => - [...searchHistoryKeys.lists(), organizationId ?? '', userId ?? ''] as const, -} +import { + SEARCH_HISTORY_STALE_TIME, + searchHistoryKeys, +} from '@/hooks/queries/utils/search-history-keys' -export function useSearchHistory(organizationId: string | undefined) { - const { data: session } = useSession() +export function useSearchHistory(organizationId: string | undefined, userId: string | undefined) { return useQuery({ - queryKey: searchHistoryKeys.list(organizationId, session?.user?.id), + queryKey: searchHistoryKeys.list(organizationId, userId), queryFn: ({ signal }) => requestJson(listSearchHistoryContract, { params: { id: organizationId! }, signal }), - enabled: Boolean(organizationId && session?.user?.id), + enabled: Boolean(organizationId && userId), staleTime: SEARCH_HISTORY_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/search-integrations.ts b/apps/sim/hooks/queries/search-integrations.ts index ba870ae1a85..95c74c4b9f2 100644 --- a/apps/sim/hooks/queries/search-integrations.ts +++ b/apps/sim/hooks/queries/search-integrations.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { @@ -6,16 +6,16 @@ import { type UpdateSearchIntegrationBody, updateSearchIntegrationContract, } from '@/lib/api/contracts/knowledge/search-integrations' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' -import { searchIntegrationKeys } from '@/hooks/queries/utils/search-integration-keys' - -export const SEARCH_INTEGRATIONS_STALE_TIME = 30_000 +import { + SEARCH_INTEGRATIONS_STALE_TIME, + searchIntegrationKeys, +} from '@/hooks/queries/utils/search-integration-keys' -export function useSearchIntegrations(organizationId: string, options?: { enabled?: boolean }) { - return useQuery({ +function searchIntegrationsQueryOptions(organizationId: string) { + return queryOptions({ queryKey: searchIntegrationKeys.list(organizationId), - enabled: Boolean(organizationId) && (options?.enabled ?? true), queryFn: async ({ signal }) => (await requestJson(listSearchIntegrationsContract, { query: { organizationId }, signal })) .data, @@ -23,6 +23,13 @@ export function useSearchIntegrations(organizationId: string, options?: { enable }) } +export function useSearchIntegrations(organizationId: string, options?: { enabled?: boolean }) { + return useQuery({ + ...searchIntegrationsQueryOptions(organizationId), + enabled: Boolean(organizationId) && (options?.enabled ?? true), + }) +} + export function useUpdateSearchIntegration() { const queryClient = useQueryClient() const router = useRouter() diff --git a/apps/sim/hooks/queries/sim-links.ts b/apps/sim/hooks/queries/sim-links.ts index 425f6e3157a..8817c92e90d 100644 --- a/apps/sim/hooks/queries/sim-links.ts +++ b/apps/sim/hooks/queries/sim-links.ts @@ -1,4 +1,5 @@ import { + infiniteQueryOptions, keepPreviousData, useInfiniteQuery, useMutation, @@ -15,25 +16,15 @@ import { } from '@/lib/api/contracts/sim-links' import type { SimLinkSort } from '@/lib/sim-links/validation' import type { SortDirection } from '@/lib/url-state' +import { SIM_LINKS_STALE_TIME, simLinkKeys } from '@/hooks/queries/utils/sim-link-keys' -const SIM_LINKS_STALE_TIME = 30 * 1000 - -const simLinkKeys = { - all: ['sim-links'] as const, - lists: (organizationId: string) => [...simLinkKeys.all, 'list', organizationId] as const, - list: (organizationId: string, search: string, sort: SimLinkSort, dir: SortDirection) => - [...simLinkKeys.lists(organizationId), search, sort, dir] as const, - forUrl: (organizationId: string, url: string) => - [...simLinkKeys.lists(organizationId), 'url', url] as const, -} - -export function useSimLinks( +function simLinksQueryOptions( organizationId: string, search: string, sort: SimLinkSort, dir: SortDirection ) { - return useInfiniteQuery({ + return infiniteQueryOptions({ queryKey: simLinkKeys.list(organizationId, search, sort, dir), initialPageParam: undefined as string | undefined, queryFn: ({ signal, pageParam }) => @@ -44,6 +35,17 @@ export function useSimLinks( }), getNextPageParam: (page) => page.nextCursor ?? undefined, staleTime: SIM_LINKS_STALE_TIME, + }) +} + +export function useSimLinks( + organizationId: string, + search: string, + sort: SimLinkSort, + dir: SortDirection +) { + return useInfiniteQuery({ + ...simLinksQueryOptions(organizationId, search, sort, dir), placeholderData: keepPreviousData, }) } @@ -52,7 +54,7 @@ export function useSimLinks( * Links whose destination is exactly `url`. Shares the list key prefix, so creating a link * refreshes it. */ -export function useSimLinksForUrl(organizationId: string, url: string) { +export function useSimLinksForUrl(organizationId: string, url: string, enabled = true) { return useQuery({ queryKey: simLinkKeys.forUrl(organizationId, url), queryFn: ({ signal }) => @@ -63,6 +65,7 @@ export function useSimLinksForUrl(organizationId: string, url: string) { }), select: (page) => page.links, staleTime: SIM_LINKS_STALE_TIME, + enabled: Boolean(organizationId) && enabled, }) } diff --git a/apps/sim/hooks/queries/slack-search.ts b/apps/sim/hooks/queries/slack-search.ts index 39a02552e3c..ed0230f4a89 100644 --- a/apps/sim/hooks/queries/slack-search.ts +++ b/apps/sim/hooks/queries/slack-search.ts @@ -19,7 +19,7 @@ import { SLACK_SEARCH_DEFAULT_DESCRIPTION, SLACK_SEARCH_DEFAULT_NAME, } from '@/lib/slack-search/manifest' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys' import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' export const SLACK_SEARCH_STALE_TIME = 30_000 diff --git a/apps/sim/hooks/queries/utils/optimistic-mutation.ts b/apps/sim/hooks/queries/utils/optimistic-mutation.ts index 16bab2ffc19..8ef9a80fde4 100644 --- a/apps/sim/hooks/queries/utils/optimistic-mutation.ts +++ b/apps/sim/hooks/queries/utils/optimistic-mutation.ts @@ -45,12 +45,12 @@ export function createOptimisticMutationHandlers( const tempId = generateTempId(variables) const optimisticItem = createOptimisticItem(variables, tempId) applyOptimisticUpdate(tempId, optimisticItem) - logger.info(`[${name}] Added optimistic entry: ${tempId}`) + logger.info(`Added optimistic entry: ${tempId}`, { mutation: name }) return { tempId, previousState } }, onSuccess: (data: TData, variables: TVariables, context: OptimisticMutationContext) => { - logger.info(`[${name}] Success, replacing temp entry ${context.tempId}`) + logger.info(`Success, replacing temp entry ${context.tempId}`, { mutation: name }) replaceOptimisticEntry(context.tempId, data) onSuccessExtra?.(data, variables) }, @@ -60,10 +60,10 @@ export function createOptimisticMutationHandlers( _variables: TVariables, context: OptimisticMutationContext | undefined ) => { - logger.error(`[${name}] Failed:`, error) + logger.error('Failed:', error, { mutation: name }) if (context?.previousState) { rollback(context.previousState, _variables) - logger.info(`[${name}] Rolled back to previous state`) + logger.info('Rolled back to previous state', { mutation: name }) } }, diff --git a/apps/sim/hooks/queries/utils/organization-accounts-keys.ts b/apps/sim/hooks/queries/utils/organization-accounts-keys.ts new file mode 100644 index 00000000000..50d24f293a5 --- /dev/null +++ b/apps/sim/hooks/queries/utils/organization-accounts-keys.ts @@ -0,0 +1,25 @@ +/** + * Query keys for an organization's connected accounts. Not a `'use client'` module, so a server + * prefetch can seed the same entry the client hook reads. + */ +export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 + +export const organizationAccountsKeys = { + all: ['organization-accounts'] as const, + connection: () => [...organizationAccountsKeys.all, 'connection'] as const, + personal: () => [...organizationAccountsKeys.all, 'personal'] as const, + workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, + workspace: (workspaceId?: string) => + [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, + access: (id?: string) => [...organizationAccountsKeys.detail(id), 'access'] as const, + people: (id?: string) => [...organizationAccountsKeys.detail(id), 'people'] as const, + peopleList: (id: string, search?: string, optionId?: string) => + [ + ...organizationAccountsKeys.people(id), + { search: search ?? '', optionId: optionId ?? '' }, + ] as const, + databricks: (id?: string) => [...organizationAccountsKeys.detail(id), 'databricks'] as const, + details: () => [...organizationAccountsKeys.all, 'detail'] as const, + detail: (organizationId?: string) => + [...organizationAccountsKeys.details(), organizationId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/utils/organization-secret-keys.ts b/apps/sim/hooks/queries/utils/organization-secret-keys.ts new file mode 100644 index 00000000000..f2bea222e6a --- /dev/null +++ b/apps/sim/hooks/queries/utils/organization-secret-keys.ts @@ -0,0 +1,18 @@ +import type { SecretSourceMode } from '@/lib/organization-secrets/validation' + +/** + * Query keys for an organization's secrets. Not a `'use client'` module, so a server prefetch can + * seed the same entry the client hook reads. + */ +export const ORGANIZATION_SECRET_STALE_TIME = 60 * 1000 + +export const organizationSecretKeys = { + all: ['organization-secrets'] as const, + sources: () => [...organizationSecretKeys.all, 'source'] as const, + source: (organizationId: string) => + [...organizationSecretKeys.sources(), organizationId] as const, + environments: (organizationId: string) => + [...organizationSecretKeys.all, 'environment', organizationId] as const, + environment: (organizationId: string, mode: SecretSourceMode) => + [...organizationSecretKeys.environments(organizationId), mode] as const, +} diff --git a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts index f90f4edace7..9e63d2f63fb 100644 --- a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts +++ b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts @@ -4,6 +4,18 @@ type QueryFilterKey = NonNullable< NonNullable[0]>['queryKey'] > +/** Removes a speculative read that failed and that nothing is mounted to report. */ +function dropFailedSpeculativeQuery(queryClient: QueryClient, queryKey: QueryKey): void { + const state = queryClient.getQueryState(queryKey) + if (state?.status !== 'error' || state.data !== undefined) return + + queryClient.removeQueries({ + queryKey: queryKey as QueryFilterKey, + exact: true, + type: 'inactive', + }) +} + /** * Starts a speculative query without allowing an inactive failure to poison a later mount. * Mounted observers retain the error so their component can render truthful feedback. @@ -12,14 +24,7 @@ export function prefetchQueryOnIntent ): void { - void queryClient.prefetchQuery(options).then(() => { - const state = queryClient.getQueryState(options.queryKey) - if (state?.status !== 'error' || state.data !== undefined) return - - queryClient.removeQueries({ - queryKey: options.queryKey as QueryFilterKey, - exact: true, - type: 'inactive', - }) - }) + void queryClient + .prefetchQuery(options) + .then(() => dropFailedSpeculativeQuery(queryClient, options.queryKey)) } diff --git a/apps/sim/hooks/queries/utils/search-history-keys.ts b/apps/sim/hooks/queries/utils/search-history-keys.ts new file mode 100644 index 00000000000..4d4cfdd16a6 --- /dev/null +++ b/apps/sim/hooks/queries/utils/search-history-keys.ts @@ -0,0 +1,8 @@ +export const SEARCH_HISTORY_STALE_TIME = 30_000 + +export const searchHistoryKeys = { + all: ['search-history'] as const, + lists: () => [...searchHistoryKeys.all, 'list'] as const, + list: (organizationId: string | undefined, userId: string | undefined) => + [...searchHistoryKeys.lists(), organizationId ?? '', userId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/utils/search-integration-keys.ts b/apps/sim/hooks/queries/utils/search-integration-keys.ts index e4f903a1422..f7eb9b78e88 100644 --- a/apps/sim/hooks/queries/utils/search-integration-keys.ts +++ b/apps/sim/hooks/queries/utils/search-integration-keys.ts @@ -1,3 +1,5 @@ +export const SEARCH_INTEGRATIONS_STALE_TIME = 30_000 + export const searchIntegrationKeys = { all: ['search-integrations'] as const, lists: () => [...searchIntegrationKeys.all, 'list'] as const, diff --git a/apps/sim/hooks/queries/utils/sim-link-keys.ts b/apps/sim/hooks/queries/utils/sim-link-keys.ts new file mode 100644 index 00000000000..60600ec8546 --- /dev/null +++ b/apps/sim/hooks/queries/utils/sim-link-keys.ts @@ -0,0 +1,17 @@ +import type { SimLinkSort } from '@/lib/sim-links/validation' +import type { SortDirection } from '@/lib/url-state' + +/** + * Query keys for an organization's Sim links. Not a `'use client'` module, so a server prefetch can + * seed the same entry the client hook reads. + */ +export const SIM_LINKS_STALE_TIME = 30 * 1000 + +export const simLinkKeys = { + all: ['sim-links'] as const, + lists: (organizationId: string) => [...simLinkKeys.all, 'list', organizationId] as const, + list: (organizationId: string, search: string, sort: SimLinkSort, dir: SortDirection) => + [...simLinkKeys.lists(organizationId), search, sort, dir] as const, + forUrl: (organizationId: string, url: string) => + [...simLinkKeys.lists(organizationId), 'url', url] as const, +} diff --git a/apps/sim/hooks/use-github-installation-setup.ts b/apps/sim/hooks/use-github-installation-setup.ts index b3f25d967b1..669d5a50460 100644 --- a/apps/sim/hooks/use-github-installation-setup.ts +++ b/apps/sim/hooks/use-github-installation-setup.ts @@ -19,7 +19,7 @@ import { useStartGitHubSearchSetup, } from '@/hooks/queries/github-search-setup' import { fetchOAuthCredentials, oauthCredentialKeys } from '@/hooks/queries/oauth/credentials' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys' interface GitHubInstallationSetupProps { organizationId?: string diff --git a/apps/sim/hooks/use-search-integration-connection.ts b/apps/sim/hooks/use-search-integration-connection.ts index 3596fd7c0dc..1a3849db381 100644 --- a/apps/sim/hooks/use-search-integration-connection.ts +++ b/apps/sim/hooks/use-search-integration-connection.ts @@ -9,6 +9,10 @@ import { credentialGroupOAuthCompletionChannel, isCredentialGroupOAuthFailure, } from '@/lib/credential-groups/oauth-completion' +import { + getCredentialGroupProviderService, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' import { isDesktopApp } from '@/lib/desktop' import { readSearchConnectionAttempt, @@ -19,13 +23,13 @@ import { writeSearchConnectionAttempt, } from '@/lib/knowledge/search/connection-attempt' import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' -import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { refreshConnectedAccounts } from '@/hooks/queries/organization-accounts' import { - personalSearchIntegrationKeys, useConnectPersonalSearchIntegration, usePersonalSearchIntegrations, } from '@/hooks/queries/personal-search-integrations' -import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' interface SearchIntegrationConnectionProps { organizationId: string @@ -49,6 +53,7 @@ export function useSearchIntegrationConnection({ `${controlId}:${JSON.stringify(target)}` ) const [attempt, setAttempt] = useState(() => readSearchConnectionAttempt(key)) + const [savedOpen, setSavedOpen] = useState(false) const [localError, setLocalError] = useState(null) const popup = useRef(null) const starting = useRef(false) @@ -112,13 +117,7 @@ export function useSearchIntegrationConnection({ writeSearchConnectionAttempt(key, { ...attempt, status: 'failed', error }) channel.onmessage = ({ data }: MessageEvent) => { if (isCredentialGroupOAuthFailure(data)) fail(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[data]) - else if (data === 'connected') { - void client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }) - void client.invalidateQueries({ - queryKey: searchSourceKeys.list({ kind: 'organization', organizationId }), - }) - void client.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId) }) - } + else if (data === 'connected') void refreshConnectedAccounts(client) } const timer = window.setTimeout( () => { @@ -131,7 +130,7 @@ export function useSearchIntegrationConnection({ channel.close() window.clearTimeout(timer) } - }, [attempt, key, client, organizationId]) + }, [attempt, key, client]) useEffect(() => { if (!connected || !attempt) return @@ -153,6 +152,10 @@ export function useSearchIntegrationConnection({ popup.current.focus() return } + if (target.savedProviderId) { + setSavedOpen(true) + return true + } const desktop = isDesktopApp() if (desktop && pending) return const tab = desktop ? null : window.open('about:blank', '_blank', 'width=600,height=700') @@ -217,7 +220,45 @@ export function useSearchIntegrationConnection({ error: 'Connection canceled. You can try again.', }) }, [attempt, key]) + const closeSavedConnection = useCallback(() => setSavedOpen(false), []) + const completeSavedConnection = useCallback( + (credentialId: string) => { + writeSearchConnectionAttempt(key, { + completionId: generateId(), + requestedAt: Date.now(), + status: 'connected', + credentialId, + error: null, + }) + setSavedOpen(false) + }, + [key] + ) + const connector = SEARCH_CONNECTORS.find((entry) => entry.type === target.connectorType) + const service = isCredentialGroupProvider(target.provider) + ? getCredentialGroupProviderService(target.provider) + : getServiceConfigByProviderId(target.provider) + const name = connector?.meta.name ?? service?.name ?? target.provider + const action = target.credentialId ? 'Reconnect' : 'Connect' return { + savedConnection: + savedOpen && target.savedProviderId + ? { providerId: target.savedProviderId, credentialId: target.credentialId } + : null, + closeSavedConnection, + completeSavedConnection, + action, + icon: connector?.serviceIcon ?? service?.icon, + label: connected + ? `Connected ${name}` + : query.isPending + ? `Checking ${name} connections` + : pending + ? `Waiting for ${name} connection` + : !available + ? `${name} connection is no longer available` + : `${action} ${name}`, + disabled: query.isPending || isPending || connected || (!available && !pending), connect, cancel, inventoryError: query.error?.message, diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index c59fe77ac55..6c41150b4f3 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -345,6 +345,20 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Nebius ${model}`) } + if (provider === 'thinkingmachines') { + const byokResult = await getBYOKKey(workspaceId, 'thinkingmachines', { signal }) + if (byokResult) { + logger.info('Using BYOK key for Thinking Machines', { + model, + workspaceId, + scope: byokResult.scope, + }) + return byokResult + } + if (userProvidedKey) return { apiKey: userProvidedKey, isBYOK: false } + throw new Error(`API key is required for Thinking Machines ${model}`) + } + if (provider === 'ollama-cloud') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud', { signal }) diff --git a/apps/sim/lib/api/contracts/aws-marketplace.ts b/apps/sim/lib/api/contracts/aws-marketplace.ts new file mode 100644 index 00000000000..b778985a10b --- /dev/null +++ b/apps/sim/lib/api/contracts/aws-marketplace.ts @@ -0,0 +1,117 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' + +/** + * The form AWS Marketplace posts to the fulfillment URL. It is not a route contract: the buyer's + * browser submits it as `application/x-www-form-urlencoded`, and the route answers with a redirect. + */ +export const awsMarketplaceRegistrationFormSchema = z.object({ + 'x-amzn-marketplace-token': z.string().regex(/^\S+$/, 'Registration token is required'), +}) + +/** AWS requires the registration page to collect the buyer's email; the company is optional. */ +const buyerContactSchema = z.object({ + contactEmail: z + .string() + .trim() + .min(1, 'Contact email is required') + .max(254, 'Contact email must be at most 254 characters') + .email('Enter a valid contact email'), + companyName: z + .string() + .trim() + .max(200, 'Company name must be at most 200 characters') + .regex(/^[^\r\n]*$/, 'Company name must be a single line') + .optional(), +}) + +const claimActionBodySchema = buyerContactSchema.extend({ + licenseId: z.string().min(1, 'License ID is required').max(255), +}) + +const awsMarketplaceLicenseSchema = z.object({ + id: z.string(), + status: z.enum(['pending', 'active', 'inactive']), + dimension: z.string().nullable(), + quantity: z.number().int().nullable(), + expiresAt: z.iso.datetime().nullable(), +}) + +const claimedLicenseSchema = awsMarketplaceLicenseSchema.extend({ + /** The buyer's AWS account, masked: only they should recognize it. */ + awsAccount: z.string(), + contactEmail: z.string().nullable(), + companyName: z.string().nullable(), +}) + +const awsMarketplaceClaimSchema = z.discriminatedUnion('status', [ + /** No open claim: none in this browser, or it was used, expired, or cleared by a lapse. */ + z.object({ status: z.literal('missing') }), + /** AWS has returned no entitlement yet; the buyer can leave contact details meanwhile. */ + z.object({ status: z.literal('pending'), license: claimedLicenseSchema }), + z.object({ + status: z.literal('linked'), + /** Named only when the viewer administers it. */ + organizationId: z.string().nullable(), + }), + z.object({ + status: z.literal('ready'), + license: claimedLicenseSchema, + /** Organizations the viewer owns or administers, in name order. */ + organizations: z.array(z.object({ id: z.string(), name: z.string() })), + }), +]) +export type AwsMarketplaceClaim = z.output + +/** The claim this browser holds, read from its httpOnly claim cookie. */ +export const getAwsMarketplaceClaimContract = defineRouteContract({ + method: 'GET', + path: '/api/marketplace/aws/claim', + response: { mode: 'json', schema: awsMarketplaceClaimSchema }, +}) + +export const linkAwsMarketplaceLicenseContract = defineRouteContract({ + method: 'POST', + path: '/api/marketplace/aws/claim/link', + body: claimActionBodySchema.extend({ organizationId: organizationIdSchema }), + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) + +/** Registers the claimed license to the signed-in account when no organization is chosen. */ +export const registerAwsMarketplaceLicenseToAccountContract = defineRouteContract({ + method: 'POST', + path: '/api/marketplace/aws/claim/register-account', + body: claimActionBodySchema, + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) + +/** Saves a pending buyer's contact details and asks AWS again; the claim stays open. */ +export const saveAwsMarketplaceClaimContactContract = defineRouteContract({ + method: 'POST', + path: '/api/marketplace/aws/claim/contact', + body: claimActionBodySchema, + response: { + mode: 'json', + schema: z.object({ status: z.enum(['pending', 'active', 'inactive']) }), + }, +}) + +const awsMarketplaceLicenseListSchema = z.object({ + licenses: z.array(awsMarketplaceLicenseSchema.extend({ lastVerifiedAt: z.iso.datetime() })), +}) + +/** The Marketplace licenses linked to an organization, for its billing page. */ +export const listOrganizationAwsMarketplaceLicensesContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/aws-marketplace-licenses', + params: z.object({ id: organizationIdSchema }), + response: { mode: 'json', schema: awsMarketplaceLicenseListSchema }, +}) + +/** The licenses registered to the viewer's account with no organization, for personal billing. */ +export const listAccountAwsMarketplaceLicensesContract = defineRouteContract({ + method: 'GET', + path: '/api/marketplace/aws/licenses', + response: { mode: 'json', schema: awsMarketplaceLicenseListSchema }, +}) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 6a562641921..a5498313fff 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -17,6 +17,7 @@ export const byokProviderIdSchema = z.enum([ 'nebius', 'ollama-cloud', 'kie', + 'thinkingmachines', 'falai', 'firecrawl', 'exa', diff --git a/apps/sim/lib/api/contracts/common.ts b/apps/sim/lib/api/contracts/common.ts index 2bd1c2334c7..b846d3407ae 100644 --- a/apps/sim/lib/api/contracts/common.ts +++ b/apps/sim/lib/api/contracts/common.ts @@ -13,7 +13,6 @@ export const helpFormBodySchema = z.object({ message: z.string().min(1, 'Message is required'), type: z.enum(['bug', 'feedback', 'feature_request', 'other']), }) -export type HelpFormBody = z.input export const integrationRequestBodySchema = z.object({ integrationName: z diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index ef4373f5169..da343f20cd7 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -132,6 +132,7 @@ export const credentialGroupEnrollmentMcpConnectionSchema = z.object({ mcpServerId: z.string().min(1).max(128), name: z.string().min(1).max(255), status: z.enum(['active', 'needs_reauth', 'revoked']), + count: z.number().int().positive(), }) export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({ @@ -140,7 +141,7 @@ export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSc .max((CREDENTIAL_GROUP_PROVIDER_IDS.length + 1) * 3), mcpConnections: z .array(credentialGroupEnrollmentMcpConnectionSchema) - .max(CREDENTIAL_GROUP_MCP_SERVER_LIMIT), + .max(CREDENTIAL_GROUP_MCP_SERVER_LIMIT * 3), }) export type CredentialGroupEnrollmentConnection = z.output< diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index f7e6ac573eb..611008a5d41 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -149,6 +149,7 @@ export const createCredentialFieldsSchema = z.object({ .optional(), apiToken: z.string().trim().min(1).optional(), domain: z.string().trim().min(1).optional(), + providerWorkspaceId: z.string().trim().min(1).max(256).optional(), atlassianProduct: atlassianProductSchema.optional(), /** * Client-supplied credential id, honored only for `slack-custom-bot` creates: @@ -286,6 +287,7 @@ export const updateCredentialByIdBodySchema = z /** Atlassian service-account secret rotation (reconnect). */ apiToken: z.string().trim().min(1).optional(), domain: z.string().trim().min(1).optional(), + providerWorkspaceId: z.string().trim().min(1).max(256).optional(), atlassianProduct: atlassianProductSchema.optional(), /** Client-credential service-account secret rotation (reconnect). */ clientId: z.string().trim().min(1).max(512).optional(), @@ -314,6 +316,7 @@ export const updateCredentialByIdBodySchema = z data.botToken !== undefined || data.apiToken !== undefined || data.domain !== undefined || + data.providerWorkspaceId !== undefined || data.atlassianProduct !== undefined || data.clientId !== undefined || data.clientSecret !== undefined || diff --git a/apps/sim/lib/api/contracts/knowledge/search-history.ts b/apps/sim/lib/api/contracts/knowledge/search-history.ts index 740201cc0bf..8c869edfa52 100644 --- a/apps/sim/lib/api/contracts/knowledge/search-history.ts +++ b/apps/sim/lib/api/contracts/knowledge/search-history.ts @@ -33,7 +33,6 @@ export const searchHistoryResponseSchema = z.object({ .array(z.object({ query: z.string().min(1).max(4000), searchedAt: z.iso.datetime() })) .max(SEARCH_HISTORY_LIMIT), }) -export type SearchHistoryResponse = z.output export const listSearchHistoryContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts index bef4aab0fae..f2fa99efaaf 100644 --- a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts +++ b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts @@ -458,6 +458,8 @@ export const workspaceKnowledgeSearchResultSchema = z.object({ sourceDate: z.string().nullable().optional(), sourceContainerName: z.string().optional(), sourceContainerUrl: z.string().optional(), + /** Zero-based nativeQueries entries that returned this result, retained through rank fusion. */ + queryIndexes: z.array(z.number().int().min(0).max(8)).min(1).max(9).optional(), sourceDateType: z.enum(['event_start', 'message', 'modified']).optional(), /** The person behind the document, from its author-like tag; null when the source names none. */ author: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 0203e9082c4..5e7e7df6680 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -120,6 +120,7 @@ const oauthTokenResponseSchema = z.object({ apiDomain: z.string().optional(), cloudId: z.string().optional(), domain: z.string().optional(), + providerWorkspaceId: z.string().optional(), realmId: z.string().optional(), quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(), authStyle: z.enum(['x-api-token']).optional(), diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index 18a3977ca7d..1371375c253 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -48,6 +48,17 @@ export const getOrganizationAccountsContract = defineRouteContract({ availableMcpConnectors: z.array(managedMcpConnectorIdSchema), canManage: z.boolean(), indexingAvailable: z.boolean(), + viewerSavedAccounts: z + .array( + z.object({ + credentialId: z.string().min(1).max(128), + displayName: z.string().max(512), + providerId: z.enum(['jama-service-account', 'flow-service-account']), + status: z.enum(['active', 'needs_reauth']), + }) + ) + .max(ORGANIZATION_VIEWER_ACCOUNT_LIMIT) + .optional(), viewerMcpAccounts: z .array( z.object({ diff --git a/apps/sim/lib/api/contracts/search-personal-credentials.ts b/apps/sim/lib/api/contracts/search-personal-credentials.ts new file mode 100644 index 00000000000..3579f6a0c5f --- /dev/null +++ b/apps/sim/lib/api/contracts/search-personal-credentials.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' +import { + createCredentialFieldsSchema, + refineCredentialCreate, +} from '@/lib/api/contracts/credentials' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const saveSearchPersonalCredentialBodySchema = createCredentialFieldsSchema + .pick({ + apiToken: true, + domain: true, + providerWorkspaceId: true, + clientId: true, + clientSecret: true, + orgId: true, + displayName: true, + description: true, + }) + .extend({ + organizationId: organizationIdSchema, + providerId: z.enum(['jama-service-account', 'flow-service-account']), + credentialId: z.string().min(1).max(255).optional(), + }) + .strict() + .superRefine((data, ctx) => refineCredentialCreate({ ...data, type: 'service_account' }, ctx)) + +export type SaveSearchPersonalCredentialBody = z.input< + typeof saveSearchPersonalCredentialBodySchema +> + +export const saveSearchPersonalCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/search/personal-credentials', + body: saveSearchPersonalCredentialBodySchema, + response: { mode: 'json', schema: z.object({ credentialId: z.string() }) }, +}) + +const removeSearchPersonalCredentialBodySchema = z + .object({ + organizationId: organizationIdSchema, + credentialId: z.string().min(1).max(255), + }) + .strict() + +export type RemoveSearchPersonalCredentialBody = z.input< + typeof removeSearchPersonalCredentialBodySchema +> + +export const removeSearchPersonalCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/search/personal-credentials', + body: removeSearchPersonalCredentialBodySchema, + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts index 26ae84dde19..55627b285fa 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts @@ -749,6 +749,46 @@ export const adminDashboardIssueEnterpriseContract = defineRouteContract({ }, }) +const adminDashboardAwsMarketplaceLicenseSchema = z.object({ + id: z.string(), + licenseArn: z.string(), + customerAwsAccountId: z.string(), + agreementId: z.string().nullable(), + status: z.enum(['pending', 'active', 'inactive']), + dimension: z.string().nullable(), + quantity: z.number().int().nullable(), + expiresAt: z.string().nullable(), + lastVerifiedAt: z.string(), + licenseEventReceivedAt: z.string().nullable(), + linkedByUserId: z.string().nullable(), + contactEmail: z.string().nullable(), + companyName: z.string().nullable(), + createdAt: z.string(), +}) + +export const adminDashboardListAwsMarketplaceLicensesContract = defineRouteContract({ + method: 'GET', + path: '/api/v1/admin/dashboard/aws-marketplace-licenses', + query: adminV1PaginationQuerySchema, + response: { + mode: 'json', + schema: adminV1ListResponseSchema(adminDashboardAwsMarketplaceLicenseSchema), + }, +}) + +export const adminDashboardLinkAwsMarketplaceLicenseContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/admin/dashboard/aws-marketplace-licenses', + body: z.object({ + licenseId: z.string().min(1, 'License ID is required').max(255), + organizationId: z.string().min(1, 'Organization ID is required').max(255), + }), + response: { + mode: 'json', + schema: adminV1SingleResponseSchema(z.object({ id: z.string(), organizationId: z.string() })), + }, +}) + export const adminDashboardCreateEnterpriseOwnerClaimContract = defineRouteContract({ method: 'POST', path: '/api/v1/admin/dashboard/enterprise-owner-claims', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 5aef4e4a542..b5be8e98215 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -428,6 +428,13 @@ const v2ServiceAccountCredentialFieldsSchema = z .describe('Write-only provider API token.') .meta({ writeOnly: true }), domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + providerWorkspaceId: z + .string() + .trim() + .min(1) + .max(256) + .optional() + .describe('Provider workspace identifier, separate from the Sim workspace.'), atlassianProduct: atlassianProductSchema .optional() .describe( @@ -698,6 +705,13 @@ const v2ServiceAccountSecretFieldsShape = { .describe('Write-only provider API token.') .meta({ writeOnly: true }), domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + providerWorkspaceId: z + .string() + .trim() + .min(1) + .max(256) + .optional() + .describe('Provider workspace identifier, separate from the Sim workspace.'), atlassianProduct: atlassianProductSchema .optional() .describe( diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index f25b92b4e3e..eec5c4f760d 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -160,6 +160,11 @@ export const listWorkspacesContract = defineRouteContract({ */ pinnedWorkspaceIds: z.array(z.string()).default([]), creationPolicy: workspaceCreationPolicySchema.nullable(), + /** + * Set when the viewer has no workspace, cannot create one, and belongs to an + * organization whose surface can serve them instead of a blocked picker. + */ + forwardOrganizationId: z.string().optional(), }), }, }) diff --git a/apps/sim/lib/api/server/sim-link-presenters.ts b/apps/sim/lib/api/server/sim-link-presenters.ts index 495981d81c1..2f57b060a4d 100644 --- a/apps/sim/lib/api/server/sim-link-presenters.ts +++ b/apps/sim/lib/api/server/sim-link-presenters.ts @@ -4,3 +4,7 @@ import type { SimLinkView } from '@/lib/sim-links/application/use-cases' export function presentSimLink({ normalizedName, ...link }: SimLinkView): SimLink { return { ...link, updatedAt: link.updatedAt.toISOString() } } + +export function presentSimLinkPage(page: { links: SimLinkView[]; nextCursor: string | null }) { + return { links: page.links.map(presentSimLink), nextCursor: page.nextCursor } +} diff --git a/apps/sim/lib/assistant-actions/application/review.ts b/apps/sim/lib/assistant-actions/application/review.ts index 6716e59c4c1..ba95704e3fb 100644 --- a/apps/sim/lib/assistant-actions/application/review.ts +++ b/apps/sim/lib/assistant-actions/application/review.ts @@ -38,6 +38,10 @@ interface ReviewInput { runId: string } +function nothingToReview() { + return { runId: null, streamId: null, actions: [], nextCursor: null } +} + export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseCase({ operation: assistantActionOperations.list, async execute({ @@ -64,9 +68,16 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC ) .orderBy(desc(copilotRuns.startedAt), desc(copilotRuns.id)) .limit(1) - if (!latest) return { runId: null, streamId: null, actions: [], nextCursor: null } + if (!latest) return nothingToReview() const runId = latest.id - const run = await requireAssistantRun(principal, { ...input, runId }, context.userId) + const run = await requireAssistantRun(principal, { ...input, runId }, context.userId).catch( + (error: unknown) => { + // The lookup above takes no lock, so the run can close before this one is granted. + if (error instanceof OrchestrationError && error.code === 'conflict') return null + throw error + } + ) + if (!run) return nothingToReview() const records = await db .select() .from(copilotAsyncToolCalls) diff --git a/apps/sim/lib/assistant-actions/tool-permissions.integration.ts b/apps/sim/lib/assistant-actions/tool-permissions.integration.ts index d76acee998f..6d1871a0649 100644 --- a/apps/sim/lib/assistant-actions/tool-permissions.integration.ts +++ b/apps/sim/lib/assistant-actions/tool-permissions.integration.ts @@ -25,9 +25,10 @@ import { } from '@sim/db/schema' import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' import { sha256Hex } from '@sim/security/hash' +import { createDeferred } from '@sim/testing/helpers/deferred' import { generateId } from '@sim/utils/id' import { toArray, toRecord } from '@sim/utils/object' -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, ne, sql } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { claimOrganizationAssistantAction, @@ -639,6 +640,56 @@ describe('organization Assistant tool permissions', () => { } ) + it('reads a run that closes during the review read as nothing to review', async () => { + await change(0, 'ask') + await prepare() + const held = createDeferred() + const release = createDeferred() + const closing = db.transaction(async (tx) => { + const [blocker] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + if (!blocker) throw new Error('Missing closing transaction') + await tx.update(copilotChats).set({ conversationId: null }).where(eq(copilotChats.id, chatId)) + await tx + .update(copilotRuns) + .set({ status: 'complete', toolAdmissionClosedAt: new Date() }) + .where(eq(copilotRuns.id, runId)) + held.resolve(blocker.pid) + await release.promise + }) + void closing.catch((error: unknown) => held.reject(error)) + try { + const blockerPid = await held.promise + const reading = review() + void reading.catch(() => undefined) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + }, + { timeout: 10_000, interval: 25 } + ) + release.resolve() + await closing + expect(await reading).toEqual({ + runId: null, + streamId: null, + actions: [], + nextCursor: null, + }) + } finally { + release.resolve() + await Promise.allSettled([closing]) + await db + .update(copilotChats) + .set({ conversationId: streamId }) + .where(eq(copilotChats.id, chatId)) + } + }) + it('pages eligible proposals past expired rows and keeps canonical turn metadata', async () => { await change(0, 'ask') const expired = [] @@ -1045,13 +1096,13 @@ describe('Managed account identity across Assistant approvals', () => { accepted: false, }, { - name: 'create refuses another subject in a disconnected account slot', + name: 'create adds another subject separately, leaving the approval on the disconnected account', provider: 'jira', mode: 'restore', changedSubject: true, storedTenant: null, returnedTenant: null, - accepted: false, + accepted: true, }, { name: 'create restores the same disconnected account', @@ -1123,6 +1174,8 @@ describe('Managed account identity across Assistant approvals', () => { if (!originalCredential || !originalEnrollment || !group || !configuredOption) throw new Error('Managed account fixture is incomplete') + const addsSeparateAccount = + scenario.mode === 'new' || (scenario.mode === 'restore' && scenario.changedSubject) const option = scenario.mode === 'new' ? { ...configuredOption, id: generateId(), label: 'Second Jira account' } @@ -1276,10 +1329,10 @@ describe('Managed account identity across Assistant approvals', () => { if (scenario.accepted) { const result = await completion expect(result).toMatchObject({ - created: scenario.mode === 'new', + created: addsSeparateAccount, displayName: chosenEmail, }) - if (scenario.mode === 'new') expect(result.credentialId).not.toBe(selectedCredentialId) + if (addsSeparateAccount) expect(result.credentialId).not.toBe(selectedCredentialId) else expect(result.credentialId).toBe(selectedCredentialId) const [connected] = await db .select() @@ -1303,7 +1356,7 @@ describe('Managed account identity across Assistant approvals', () => { await expect(completion).rejects.toMatchObject({ statusCode: 409 }) } - if (!scenario.accepted || scenario.mode === 'new') { + if (!scenario.accepted || addsSeparateAccount) { const [unchanged] = await db .select() .from(credential) @@ -1312,7 +1365,7 @@ describe('Managed account identity across Assistant approvals', () => { } if (pending) { - if (scenario.mode === 'restore' && !scenario.accepted) { + if (scenario.mode === 'restore' && (!scenario.accepted || addsSeparateAccount)) { await expect(claim(pending.call)).rejects.toThrow() expect(requests).toEqual([]) } else { @@ -1347,13 +1400,22 @@ describe('Managed account identity across Assistant approvals', () => { expect(receipts.receipts[0]?.account).toMatchObject({ credentialId: selectedCredentialId, displayName: - scenario.accepted && scenario.mode !== 'new' ? chosenEmail : beforeOAuth.displayName, + scenario.accepted && !addsSeparateAccount ? chosenEmail : beforeOAuth.displayName, }) } } finally { provider.mockRestore() + if (addsSeparateAccount) { + await db + .delete(credential) + .where( + and( + eq(credential.credentialGroupOptionId, option.id), + ne(credential.id, selectedCredentialId) + ) + ) + } if (scenario.mode === 'new') { - await db.delete(credential).where(eq(credential.credentialGroupOptionId, option.id)) await db .update(credentialGroup) .set({ options: group.options }) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3273c3a7d86..31ea8978ad5 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -194,12 +194,13 @@ function logUnresolvedStripePlan( priceChanged: boolean ): void { if (isEnterprise(details.dbPlan) || (isLegacyPlan(details.dbPlan) && !priceChanged)) { - logger.info(`[${hook}] Off-catalog Stripe price; keeping the stored plan`, details) + logger.info('Off-catalog Stripe price; keeping the stored plan', details, { hook }) return } logger.error( - `[${hook}] Could not resolve plan from Stripe price; keeping the stored plan — check env var configuration`, - details + 'Could not resolve plan from Stripe price; keeping the stored plan — check env var configuration', + details, + { hook } ) } diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index 5ac970bb40d..da71750cd50 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -3,6 +3,8 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { APIError } from 'better-auth/api' import { and, eq, isNotNull } from 'drizzle-orm' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { getOrganizationCoverageForMember, @@ -55,6 +57,22 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise }) } +/** + * Refuse a Stripe checkout for a reference AWS Marketplace bills. A listed product must be billed + * entirely through AWS, and such a customer looks Free to Stripe, so a checkout would start a + * second, real subscription. The client hides plans too, but only this check holds when its + * license lookup fails. + */ +async function assertNotBilledThroughMarketplace(referenceId: string): Promise { + if (!getAwsMarketplaceProductCode()) return + if (!(await holdsMarketplaceLicense(referenceId))) return + + logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId }) + throw new APIError('CONFLICT', { + message: 'This subscription is billed through AWS Marketplace. Contact support to change it.', + }) +} + /** * Classify a `/subscription/upgrade` request as a personal checkout using * the same reference resolution as the Better Auth Stripe plugin @@ -81,9 +99,10 @@ export function isPersonalCheckoutRequest( /** * Guard for personal (user-referenced) checkouts on `/subscription/upgrade`. * - * Blocks both a bound, incomplete Stripe subscription and a member already - * covered by an entitled organization subscription. Throws {@link APIError} - * with a user-facing message; the checkout UI surfaces it as-is. + * Blocks an account AWS Marketplace bills, a bound, incomplete Stripe + * subscription, and a member already covered by an entitled organization + * subscription. Throws {@link APIError} with a user-facing message; the + * checkout UI surfaces it as-is. * * Called from the Better Auth `hooks.before` middleware, NOT from * `authorizeReference`: the Stripe plugin skips `authorizeReference` @@ -94,6 +113,7 @@ export function isPersonalCheckoutRequest( * rejected rather than risking a duplicate subscription. */ export async function assertPersonalCheckoutAllowed(userId: string): Promise { + await assertNotBilledThroughMarketplace(userId) await assertNoBoundIncompleteSubscription(userId) const coverage = await getOrganizationCoverageForMember(userId) @@ -130,6 +150,7 @@ export async function assertPersonalCheckoutAllowed(userId: string): Promise ({ + id: license.id, + licenseArn: license.licenseArn, + customerAwsAccountId: license.customerAwsAccountId, + agreementId: license.agreementId, + status: license.status, + dimension: license.dimension, + quantity: license.quantity, + expiresAt: license.expiresAt?.toISOString() ?? null, + lastVerifiedAt: license.lastVerifiedAt.toISOString(), + licenseEventReceivedAt: license.licenseEventReceivedAt?.toISOString() ?? null, + linkedByUserId: license.linkedByUserId, + contactEmail: license.contactEmail, + companyName: license.companyName, + createdAt: license.createdAt.toISOString(), + })), + } +} + +/** + * The team links a license to the organization it provisioned for the buyer. A license linked + * elsewhere is refused, as it is for a buyer; linking it to the same organization again changes + * nothing and is not audited. + */ +export async function linkAwsMarketplaceLicenseAsAdmin( + input: { licenseId: string; organizationId: string }, + actor: AdminMutationActor +): Promise<{ id: string; organizationId: string }> { + const { license, newlyLinked } = await db.transaction(async (tx) => { + const locked = await lockLicense(tx, input.licenseId) + if (!locked) throw new OrchestrationError('not_found', 'License') + requireLinkableTo(locked, input.organizationId) + if (!(await organizationExists(tx, input.organizationId))) { + throw new OrchestrationError('not_found', 'Organization') + } + if (locked.organizationId === input.organizationId) { + return { license: locked, newlyLinked: false } + } + return { + license: await linkLicenseForTeam(tx, input.licenseId, input.organizationId), + newlyLinked: true, + } + }) + if (newlyLinked) { + recordAudit({ + actorId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, + resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, + resourceId: license.id, + resourceName: license.dimension ?? 'AWS Marketplace license', + description: 'Admin linked an AWS Marketplace subscription to an organization', + metadata: { ...licenseAuditMetadata(license), organizationId: input.organizationId }, + }) + } + return { id: license.id, organizationId: input.organizationId } +} diff --git a/apps/sim/lib/billing/aws-marketplace/application/operations.ts b/apps/sim/lib/billing/aws-marketplace/application/operations.ts new file mode 100644 index 00000000000..e2b4c8cdf5c --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/application/operations.ts @@ -0,0 +1,47 @@ +import { defineOperation } from '@/lib/core/application/operation' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' + +/** + * Only a signed-in person in the browser that holds the claim cookie takes part: AWS hands the + * buyer a claim, never an API client or a delegated assistant. + */ +export const awsMarketplaceOperations = { + // permission-group-exempt: reads the caller's own pending Marketplace claim and the organizations they administer. + readClaim: defineOperation({ + id: 'aws_marketplace.claim.read', + principalKinds: ['session'], + capability: 'none', + }), + // permission-group-exempt: a buyer with no organization takes their own purchase; nothing a group governs is touched. + registerToAccount: defineOperation({ + id: 'aws_marketplace.license.register_account', + principalKinds: ['session'], + capability: 'none', + }), + // permission-group-exempt: a buyer waiting on AWS leaves contact details for their own purchase. + saveClaimContact: defineOperation({ + id: 'aws_marketplace.claim.save_contact', + principalKinds: ['session'], + capability: 'none', + }), + // permission-group-exempt: a buyer sees the status of a subscription registered to their own account. + listAccountLicenses: defineOperation({ + id: 'aws_marketplace.license.list_account', + principalKinds: ['session'], + capability: 'none', + }), + // permission-group-exempt: members see their organization's subscription status, as the billing page shows its plan. + listOrganizationLicenses: defineOrganizationOperation({ + id: 'organization.aws_marketplace_license.list', + minimumRole: 'member', + principalKinds: ['session'], + capability: 'none', + }), + // permission-group-exempt: linking a purchased license is organization administration, not a governed capability. + linkLicense: defineOrganizationOperation({ + id: 'organization.aws_marketplace_license.link', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'none', + }), +} as const diff --git a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts new file mode 100644 index 00000000000..4e1d3ec5383 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts @@ -0,0 +1,380 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace' +import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations' +import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' +import { + type AwsMarketplaceLicenseRecord, + consumeLicenseClaim, + findLicenseByClaimHash, + isNewerRead, + listAccountLicenses, + listAdministeredOrganizations, + listOrganizationLicenses, + lockLicense, + recordLicenseVerification, + saveLicenseContact, +} from '@/lib/billing/aws-marketplace/repository' +import { + defineAuthorizedOrganizationUseCase, + type OrganizationUseCaseContext, +} from '@/lib/core/application/authorized-organization-use-case' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application/operation' +import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */ +function requireProductCode(): string { + const productCode = getAwsMarketplaceProductCode() + if (!productCode) throw new OrchestrationError('not_found', 'Not found') + return productCode +} + +function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean { + return Boolean(license.claimExpiresAt && license.claimExpiresAt.getTime() > now.getTime()) +} + +const NO_ACTIVE_SUBSCRIPTION = 'AWS Marketplace reports no active subscription for this license.' +const SUBSCRIPTION_UNCONFIRMED = + 'AWS is still confirming your subscription. Try again in a few minutes.' + +/** + * Under the row lock, after the claim is rechecked: a newer read that still says pending wins over + * this request's. A newer read that says inactive already cleared the claim. + */ +function requireNotOverruled(locked: AwsMarketplaceLicenseRecord, verifiedAt: Date): void { + if (locked.status === 'pending' && !isNewerRead(locked, verifiedAt)) { + throw new OrchestrationError('conflict', SUBSCRIPTION_UNCONFIRMED) + } +} + +const CLAIM_NOT_FOUND = + 'This AWS Marketplace setup link is no longer valid. Open your subscription in AWS Marketplace and choose Set up your account again.' + +/** Refuses unless this browser's claim still names this license and has not expired. */ +function requireOpenClaim( + license: AwsMarketplaceLicenseRecord | null, + licenseId: string, + claimHash: string | null +): AwsMarketplaceLicenseRecord { + if (!license || !claimHash || license.id !== licenseId || license.claimTokenHash !== claimHash) { + throw new OrchestrationError('forbidden', CLAIM_NOT_FOUND) + } + if (!isClaimOpen(license, new Date())) { + throw new OrchestrationError( + 'forbidden', + 'This AWS Marketplace setup link has expired. Open your subscription in AWS Marketplace and choose Set up your account again.' + ) + } + return license +} + +/** A license linked to one organization is never moved, and never taken without one. */ +export function requireLinkableTo( + license: AwsMarketplaceLicenseRecord, + organizationId: string | null +): void { + if (license.organizationId && license.organizationId !== organizationId) { + throw new OrchestrationError( + 'conflict', + 'This AWS Marketplace subscription is already linked to another organization.' + ) + } +} + +/** Enough for a buyer to recognize their account, and no more for someone holding their claim. */ +function maskAwsAccount(accountId: string): string { + return `••••${accountId.slice(-4)}` +} + +function claimedLicenseView(license: AwsMarketplaceLicenseRecord) { + return { + id: license.id, + status: license.status, + dimension: license.dimension, + quantity: license.quantity, + expiresAt: license.expiresAt?.toISOString() ?? null, + awsAccount: maskAwsAccount(license.customerAwsAccountId), + contactEmail: license.contactEmail, + companyName: license.companyName, + } +} + +/** + * The pending claim this browser holds, and where the signed-in person could link it. Who a linked + * license belongs to is said only to someone who administers that organization. + */ +export const readAwsMarketplaceClaim: OperationUseCase< + typeof awsMarketplaceOperations.readClaim, + { claimToken: string | null }, + AwsMarketplaceClaim +> = { + operation: awsMarketplaceOperations.readClaim, + async execute({ principal, input }) { + assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim) + requireProductCode() + if (!input.claimToken) return { status: 'missing' } + const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken)) + if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' } + if (license.status === 'pending') { + return { status: 'pending', license: claimedLicenseView(license) } + } + const organizations = await listAdministeredOrganizations(principal.userId) + if (license.organizationId) { + return { + status: 'linked', + organizationId: organizations.some((org) => org.id === license.organizationId) + ? license.organizationId + : null, + } + } + return { status: 'ready', license: claimedLicenseView(license), organizations } + }, +} + +interface ClaimInput { + licenseId: string + /** The raw claim token from this browser's httpOnly cookie, or null when it holds none. */ + claimToken: string | null +} + +interface BuyerContact { + /** Whom to contact about provisioning; AWS asks the registration page to collect it. */ + contactEmail: string + companyName: string | null +} + +type TakeInput = ClaimInput & BuyerContact + +interface LinkLicenseInput extends TakeInput { + organizationId: string +} + +interface LinkedLicense { + license: AwsMarketplaceLicenseRecord + /** False when the license already belonged here; nothing new to audit or announce. */ + newlyLinked: boolean +} + +function contactOf(input: BuyerContact) { + return { email: input.contactEmail, companyName: input.companyName } +} + +/** + * Checks this browser's claim, then asks the Entitlement Service again: a license is taken only + * while AWS says it is current. An answer that is not is recorded before the refusal. The caller + * rechecks the claim under the row lock, so two tabs racing one claim take it at most once. + */ +async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) { + const productCode = requireProductCode() + const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null + const claimed = requireOpenClaim( + claimHash ? await findLicenseByClaimHash(claimHash) : null, + input.licenseId, + claimHash + ) + requireLinkableTo(claimed, organizationId) + + const verifiedAt = new Date() + const summary = await verifyLicense(productCode, claimed.licenseArn) + const verification = { summary, verifiedAt } + if (!summary.active) { + const outcome = await recordLicenseVerification({ + licenseArn: claimed.licenseArn, + productCode, + verification, + }) + throw new OrchestrationError( + 'conflict', + outcome.kind === 'updated' && outcome.license.status === 'pending' + ? SUBSCRIPTION_UNCONFIRMED + : NO_ACTIVE_SUBSCRIPTION + ) + } + return { claimHash, verification } +} + +export function licenseAuditMetadata(license: AwsMarketplaceLicenseRecord) { + return { + dimension: license.dimension, + quantity: license.quantity, + expiresAt: license.expiresAt?.toISOString() ?? null, + } +} + +/** + * Links the license this browser's claim names to an organization the caller administers. A + * license belongs to at most one organization; one registered to an account and no organization + * yet may still be linked. + */ +export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ + operation: awsMarketplaceOperations.linkLicense, + async execute({ + input, + context, + }: OrganizationUseCaseContext): Promise { + const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId) + return db.transaction(async (tx) => { + const locked = requireOpenClaim( + await lockLicense(tx, input.licenseId), + input.licenseId, + claimHash + ) + requireLinkableTo(locked, input.organizationId) + requireNotOverruled(locked, verification.verifiedAt) + // Holds the admin's membership until commit, so a concurrent demotion waits for the link. + await requireOrganizationSubjectMembership( + context.userId, + input.organizationId, + 'admin', + 'none', + undefined, + { executor: tx, forShare: true } + ) + const newlyLinked = locked.organizationId !== input.organizationId + const license = await consumeLicenseClaim(tx, locked, { + link: newlyLinked ? { organizationId: input.organizationId, userId: context.userId } : null, + contact: contactOf(input), + verification, + }) + return { license, newlyLinked } + }) + }, + projectAudit: ({ result }) => + result.newlyLinked + ? { + action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED, + resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, + resourceId: result.license.id, + resourceName: result.license.dimension ?? 'AWS Marketplace license', + description: 'Linked an AWS Marketplace subscription to the organization', + metadata: licenseAuditMetadata(result.license), + } + : [], +}) + +/** + * Registers the license this browser's claim names to the signed-in account, with no + * organization: a new buyer who administers none still completes setup, and the team creates + * the Enterprise organization and links it. A license already linked to an organization is + * refused; registering it here never moves it. + */ +export const registerAwsMarketplaceLicenseToAccount: OperationUseCase< + typeof awsMarketplaceOperations.registerToAccount, + TakeInput, + { licenseId: string } +> = { + operation: awsMarketplaceOperations.registerToAccount, + async execute({ principal, input, request }) { + assertOperationPrincipal(principal, awsMarketplaceOperations.registerToAccount) + const { claimHash, verification } = await verifyClaimedLicense(input, null) + const { license, newlyRegistered } = await db.transaction(async (tx) => { + const locked = requireOpenClaim( + await lockLicense(tx, input.licenseId), + input.licenseId, + claimHash + ) + requireLinkableTo(locked, null) + requireNotOverruled(locked, verification.verifiedAt) + const newlyRegistered = locked.linkedByUserId !== principal.userId + const consumed = await consumeLicenseClaim(tx, locked, { + link: newlyRegistered ? { organizationId: null, userId: principal.userId } : null, + contact: contactOf(input), + verification, + }) + return { license: consumed, newlyRegistered } + }) + if (newlyRegistered) { + recordProjectedUseCaseAuditEntries( + awsMarketplaceOperations.registerToAccount, + null, + principal, + request, + [ + { + action: AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED, + resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE, + resourceId: license.id, + resourceName: license.dimension ?? 'AWS Marketplace license', + description: 'Registered an AWS Marketplace subscription to an account', + metadata: licenseAuditMetadata(license), + }, + ] + ) + } + return { licenseId: license.id } + }, +} + +/** + * While AWS is still confirming a license, its buyer leaves contact details here and asks AWS + * again. The claim stays open: linking waits for a read that says the license is current. + */ +export const saveAwsMarketplaceClaimContact: OperationUseCase< + typeof awsMarketplaceOperations.saveClaimContact, + TakeInput, + { status: 'pending' | 'active' | 'inactive' } +> = { + operation: awsMarketplaceOperations.saveClaimContact, + async execute({ principal, input }) { + assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact) + const productCode = requireProductCode() + const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null + const claimed = requireOpenClaim( + claimHash ? await findLicenseByClaimHash(claimHash) : null, + input.licenseId, + claimHash + ) + const verifiedAt = new Date() + const summary = await verifyLicense(productCode, claimed.licenseArn) + return db.transaction(async (tx) => { + const locked = requireOpenClaim( + await lockLicense(tx, input.licenseId), + input.licenseId, + claimHash + ) + const license = await saveLicenseContact(tx, locked, { + contact: contactOf(input), + verification: { summary, verifiedAt }, + }) + return { status: license.status } + }) + }, +} + +function toLicenseStatusList(rows: Awaited>) { + return rows.map((row) => ({ + ...row, + expiresAt: row.expiresAt?.toISOString() ?? null, + lastVerifiedAt: row.lastVerifiedAt.toISOString(), + })) +} + +/** + * The Marketplace licenses linked to an organization, so its members can see their subscription + * in Sim. Empty where Sim does not sell on AWS, so the billing page shows nothing there. + */ +export const listOrganizationAwsMarketplaceLicenses = defineAuthorizedOrganizationUseCase({ + operation: awsMarketplaceOperations.listOrganizationLicenses, + async execute({ context }: OrganizationUseCaseContext<{ organizationId: string }>) { + if (!getAwsMarketplaceProductCode()) return [] + return toLicenseStatusList(await listOrganizationLicenses(context.organizationId)) + }, +}) + +/** The licenses registered to the caller's account with no organization yet, for their billing page. */ +export const listAccountAwsMarketplaceLicenses: OperationUseCase< + typeof awsMarketplaceOperations.listAccountLicenses, + Record, + ReturnType +> = { + operation: awsMarketplaceOperations.listAccountLicenses, + async execute({ principal }) { + assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses) + if (!getAwsMarketplaceProductCode()) return [] + return toLicenseStatusList(await listAccountLicenses(principal.userId)) + }, +} diff --git a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts new file mode 100644 index 00000000000..4a7ff2aeaa5 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts @@ -0,0 +1,168 @@ +import { db } from '@sim/db' +import { awsMarketplaceLicense, member, organization, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { inArray } from 'drizzle-orm' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +const PRODUCT = 'product-placeholder' +const ACCOUNT = '000000000000' +const TEAM_PLAN = 'team_6000' + +const productCode = vi.hoisted(() => ({ current: 'product-placeholder' as string | null })) + +vi.mock('@/lib/billing/aws-marketplace/config', () => ({ + getAwsMarketplaceProductCode: () => productCode.current, +})) + +import { + assertPersonalCheckoutAllowed, + authorizeSubscriptionReference, +} from '@/lib/billing/authorization' + +type LicenseStatus = (typeof awsMarketplaceLicense.$inferInsert)['status'] + +const MARKETPLACE_REFUSAL = /billed through AWS Marketplace/ + +const userIds: string[] = [] +const organizationIds: string[] = [] + +async function createUser(): Promise { + const id = generateId() + userIds.push(id) + await db.insert(user).values({ + id, + name: 'Checkout guard fixture', + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + return id +} + +/** An organization `ownerId` owns, so every check but the one under test admits the checkout. */ +async function createOwnedOrganization(ownerId: string): Promise { + const id = generateId() + organizationIds.push(id) + await db + .insert(organization) + .values({ id, name: 'Checkout guard org', slug: id, createdAt: new Date() }) + await db.insert(member).values({ + id: generateId(), + organizationId: id, + userId: ownerId, + role: 'owner', + createdAt: new Date(), + }) + return id +} + +async function insertLicense(link: { + organizationId: string | null + linkedByUserId: string + status: LicenseStatus +}) { + const now = new Date() + await db.insert(awsMarketplaceLicense).values({ + id: generateId(), + licenseArn: `license-${generateId()}`, + productCode: PRODUCT, + customerAwsAccountId: ACCOUNT, + dimension: 'enterprise', + quantity: 25, + lastVerifiedAt: now, + lastVerificationAttemptAt: now, + ...link, + }) +} + +async function organizationWithLicense(status: LicenseStatus | null) { + const ownerId = await createUser() + const organizationId = await createOwnedOrganization(ownerId) + if (status) await insertLicense({ organizationId, linkedByUserId: ownerId, status }) + return { ownerId, organizationId } +} + +afterEach(() => { + productCode.current = PRODUCT +}) + +afterAll(async () => { + if (organizationIds.length) { + await db + .delete(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.organizationId, organizationIds)) + await db.delete(organization).where(inArray(organization.id, organizationIds)) + } + if (userIds.length) { + await db + .delete(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.linkedByUserId, userIds)) + await db.delete(user).where(inArray(user.id, userIds)) + } +}) + +describe('Stripe checkout for an AWS Marketplace-billed reference', () => { + it.each(['pending', 'active'] as const)( + 'refuses an organization whose license is %s', + async (status) => { + const { ownerId, organizationId } = await organizationWithLicense(status) + + await expect( + authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) + ).rejects.toMatchObject({ + statusCode: 409, + message: expect.stringMatching(MARKETPLACE_REFUSAL), + }) + } + ) + + it('refuses a personal checkout for a user whose account holds a license', async () => { + const userId = await createUser() + await insertLicense({ organizationId: null, linkedByUserId: userId, status: 'active' }) + + await expect(assertPersonalCheckoutAllowed(userId)).rejects.toMatchObject({ + statusCode: 409, + message: expect.stringMatching(MARKETPLACE_REFUSAL), + }) + }) + + it('allows an organization whose license is inactive', async () => { + const { ownerId, organizationId } = await organizationWithLicense('inactive') + + await expect( + authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) + ).resolves.toBe(true) + }) + + it('allows an organization with no license', async () => { + const { ownerId, organizationId } = await organizationWithLicense(null) + + await expect( + authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) + ).resolves.toBe(true) + }) + + it('allows a licensed organization and account where Sim does not sell on AWS', async () => { + const { ownerId, organizationId } = await organizationWithLicense('active') + const userId = await createUser() + await insertLicense({ organizationId: null, linkedByUserId: userId, status: 'active' }) + productCode.current = null + + await expect( + authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) + ).resolves.toBe(true) + await expect(assertPersonalCheckoutAllowed(userId)).resolves.toBeUndefined() + }) + + it.each(['billing-portal', 'cancel-subscription', 'list-subscription'])( + 'still allows %s for a licensed organization', + async (action) => { + const { ownerId, organizationId } = await organizationWithLicense('active') + + await expect(authorizeSubscriptionReference(ownerId, organizationId, action)).resolves.toBe( + true + ) + } + ) +}) diff --git a/apps/sim/lib/billing/aws-marketplace/claim.ts b/apps/sim/lib/billing/aws-marketplace/claim.ts new file mode 100644 index 00000000000..d339514c083 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/claim.ts @@ -0,0 +1,28 @@ +import { sha256Hex } from '@sim/security/hash' +import { generateShortId } from '@sim/utils/id' + +/** Holds the buyer's one-time claim between the AWS redirect and linking an organization. */ +export const AWS_MARKETPLACE_CLAIM_COOKIE = 'sim_aws_marketplace_claim' + +/** Matches how long AWS keeps a registration token valid. */ +export const AWS_MARKETPLACE_CLAIM_TTL_MS = 4 * 60 * 60 * 1000 + +/** ~190 random bits; only its hash is stored. */ +export function issueClaimToken(): { token: string; hash: string } { + const token = generateShortId(32) + return { token, hash: hashClaimToken(token) } +} + +export function hashClaimToken(token: string): string { + return sha256Hex(token) +} + +/** + * The `Set-Cookie` value carrying a claim token, or clearing it when `token` is null. Lax is the + * strictest policy that lets the first read work: with Strict, the page AWS's cross-site POST + * redirects to would not send the cookie. Lax also keeps it off cross-site POSTs to the claim routes. + */ +export function serializeClaimCookie(token: string | null): string { + const maxAge = token === null ? 0 : Math.floor(AWS_MARKETPLACE_CLAIM_TTL_MS / 1000) + return `${AWS_MARKETPLACE_CLAIM_COOKIE}=${token ?? ''}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax` +} diff --git a/apps/sim/lib/billing/aws-marketplace/client.ts b/apps/sim/lib/billing/aws-marketplace/client.ts new file mode 100644 index 00000000000..44734f79345 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/client.ts @@ -0,0 +1,66 @@ +import { MarketplaceEntitlementServiceClient } from '@aws-sdk/client-marketplace-entitlement-service' +import { MarketplaceMeteringClient } from '@aws-sdk/client-marketplace-metering' +import { SQSClient } from '@aws-sdk/client-sqs' +import { fromHttp } from '@aws-sdk/credential-providers' +import { getAwsCredentials } from '@/lib/core/config/aws' +import { env } from '@/lib/core/config/env' + +/** + * The Entitlement Service exists only here. Metering is multi-region, but a registration token is + * resolved here too, and infra runs the event queue in this region. + */ +const MARKETPLACE_REGION = 'us-east-1' + +/** + * A stalled connection fails instead of hanging. The SDK's default three attempts retry throttling, + * 5xx, and these timeouts. + */ +const REQUEST_HANDLER = { + connectionTimeout: 3_000, + requestTimeout: 10_000, + throwOnRequestTimeout: true, +} + +let meteringClient: MarketplaceMeteringClient | null = null +let entitlementClient: MarketplaceEntitlementServiceClient | null = null +let queueClient: SQSClient | null = null + +/** + * Credentials for the Marketplace and event-queue clients only. On ECS the app's env also carries + * a static IAM user's keys, which the SDK's default chain prefers and which Sim's general AWS + * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, and the + * queue permissions to the ECS task role alone, and AWS asks for temporary role credentials here, + * so whenever the container credential endpoint is present these clients sign as the task role. + * Elsewhere (local development, tests) they keep the default chain. + */ +function marketplaceClientConfig() { + const relativeUri = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI + const fullUri = env.AWS_CONTAINER_CREDENTIALS_FULL_URI + return { + region: MARKETPLACE_REGION, + requestHandler: REQUEST_HANDLER, + credentials: + relativeUri || fullUri + ? fromHttp({ + awsContainerCredentialsRelativeUri: relativeUri, + awsContainerCredentialsFullUri: fullUri, + }) + : getAwsCredentials(), + } +} + +/** Built on first use, so a deployment with the feature off never reaches for credentials. */ +export function getMeteringClient(): MarketplaceMeteringClient { + meteringClient ??= new MarketplaceMeteringClient(marketplaceClientConfig()) + return meteringClient +} + +export function getEntitlementClient(): MarketplaceEntitlementServiceClient { + entitlementClient ??= new MarketplaceEntitlementServiceClient(marketplaceClientConfig()) + return entitlementClient +} + +export function getEventsQueueClient(): SQSClient { + queueClient ??= new SQSClient(marketplaceClientConfig()) + return queueClient +} diff --git a/apps/sim/lib/billing/aws-marketplace/config.ts b/apps/sim/lib/billing/aws-marketplace/config.ts new file mode 100644 index 00000000000..778a029e5b0 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/config.ts @@ -0,0 +1,10 @@ +import { env } from '@/lib/core/config/env' + +/** + * The SaaS product code, or null when this deployment does not sell through AWS Marketplace: + * self-hosted installs and every environment but production. Null turns the registration, + * claim, and reconcile surfaces off. + */ +export function getAwsMarketplaceProductCode(): string | null { + return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null +} diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts new file mode 100644 index 00000000000..56088443a8c --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts @@ -0,0 +1,107 @@ +import type { Entitlement } from '@aws-sdk/client-marketplace-entitlement-service' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() })) + +vi.mock('@aws-sdk/client-marketplace-entitlement-service', () => ({ + MarketplaceEntitlementServiceClient: class { + send = mockSend + }, + GetEntitlementsCommand: class { + constructor(public input: { NextToken?: string }) {} + }, +})) + +import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' + +const PRODUCT = 'product-placeholder' +const LICENSE = 'license-placeholder' +const NOW = new Date('2026-06-01T00:00:00.000Z') + +function entitlement(overrides: Partial = {}): Entitlement { + return { + ProductCode: PRODUCT, + Dimension: 'enterprise', + Value: { IntegerValue: 25 }, + ...overrides, + } +} + +function read(...entitlements: Entitlement[]) { + mockSend.mockResolvedValueOnce({ Entitlements: entitlements }) + return verifyLicense(PRODUCT, LICENSE) +} + +describe('verifyLicense', () => { + beforeEach(() => { + mockSend.mockReset() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reads an entitlement that expired before now as inactive', async () => { + const summary = await read( + entitlement({ ExpirationDate: new Date('2026-05-31T23:59:59.000Z') }) + ) + expect(summary).toMatchObject({ active: false, entitlementCount: 1, quantity: 25 }) + }) + + it('reads an entitlement with a future expiry, or none at all, as active', async () => { + const future = entitlement({ ExpirationDate: new Date('2027-06-01T00:00:00.000Z') }) + expect((await read(future)).active).toBe(true) + expect((await read(entitlement())).active).toBe(true) + }) + + it('treats an entitlement expiring exactly now as lapsed', async () => { + expect((await read(entitlement({ ExpirationDate: NOW }))).active).toBe(false) + }) + + it('reads the same lapsed dimension whichever order AWS returns ties in', async () => { + const expired = new Date('2026-01-01T00:00:00.000Z') + const alpha = entitlement({ Dimension: 'alpha', ExpirationDate: expired }) + const zeta = entitlement({ Dimension: 'zeta', ExpirationDate: expired }) + + expect((await read(alpha, zeta)).dimension).toBe('alpha') + expect((await read(zeta, alpha)).dimension).toBe('alpha') + }) + + it('describes the current entitlement when another one has lapsed', async () => { + const summary = await read( + entitlement({ + Dimension: 'a-lapsed', + ExpirationDate: new Date('2026-01-01T00:00:00.000Z'), + Value: { IntegerValue: 5 }, + }), + entitlement({ + Dimension: 'b-current', + ExpirationDate: new Date('2027-01-01T00:00:00.000Z'), + Value: { IntegerValue: 50 }, + }) + ) + expect(summary).toMatchObject({ active: true, dimension: 'b-current', quantity: 50 }) + }) + + it('reads quantity only from an integer value, never from another value kind', async () => { + expect((await read(entitlement({ Value: { DoubleValue: 1200.5 } }))).quantity).toBeNull() + }) + + it('follows NextToken through an empty page to the last one', async () => { + mockSend + .mockResolvedValueOnce({ Entitlements: [entitlement({ Dimension: 'one' })], NextToken: 't1' }) + .mockResolvedValueOnce({ Entitlements: [], NextToken: 't2' }) + .mockResolvedValueOnce({ Entitlements: [entitlement({ Dimension: 'two' })] }) + + const summary = await verifyLicense(PRODUCT, LICENSE) + + expect(summary).toMatchObject({ entitlementCount: 2, dimension: 'one' }) + expect(mockSend.mock.calls.map(([command]) => command.input.NextToken)).toEqual([ + undefined, + 't1', + 't2', + ]) + }) +}) diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.ts new file mode 100644 index 00000000000..0b8ef4e454c --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/entitlements.ts @@ -0,0 +1,96 @@ +import { + type Entitlement, + GetEntitlementsCommand, +} from '@aws-sdk/client-marketplace-entitlement-service' +import { compareStrings } from '@sim/utils/string' +import { getEntitlementClient } from '@/lib/billing/aws-marketplace/client' + +/** A license carries one entitlement per purchased dimension; this bounds a runaway `NextToken`. */ +const MAX_PAGES = 20 + +/** What the last Entitlement Service read says about one license. */ +export interface EntitlementSummary { + /** At least one entitlement has no expiry or expires in the future. */ + active: boolean + /** Entitlements AWS returned, current or lapsed; zero is ambiguous, see the license's status. */ + entitlementCount: number + dimension: string | null + quantity: number | null + expiresAt: Date | null + customerAwsAccountId: string | null +} + +function isCurrent(entitlement: Entitlement, now: Date): boolean { + return !entitlement.ExpirationDate || entitlement.ExpirationDate.getTime() > now.getTime() +} + +/** + * Reduces a license's entitlements to the one the row records. A current entitlement wins, the + * lowest dimension name first so repeated reads agree; with none current, the latest-expiring one + * still describes what lapsed. Quantity is read only from an integer value: a contract's dollar + * amount is never inferred from it. + */ +function summarizeEntitlements( + entitlements: readonly Entitlement[], + now: Date +): EntitlementSummary { + const current = entitlements + .filter((entitlement) => isCurrent(entitlement, now)) + .sort((left, right) => compareStrings(left.Dimension ?? '', right.Dimension ?? '')) + const lapsed = [...entitlements].sort( + (left, right) => + (right.ExpirationDate?.getTime() ?? 0) - (left.ExpirationDate?.getTime() ?? 0) || + compareStrings(left.Dimension ?? '', right.Dimension ?? '') + ) + const chosen = current[0] ?? lapsed[0] + return { + active: current.length > 0, + entitlementCount: entitlements.length, + dimension: chosen?.Dimension ?? null, + quantity: chosen?.Value?.IntegerValue ?? null, + expiresAt: chosen?.ExpirationDate ?? null, + customerAwsAccountId: chosen?.CustomerAWSAccountId ?? null, + } +} + +/** + * The Entitlement Service rejected the request itself (a malformed license ARN or product code). + * Asking again cannot succeed; every other failure, including a misconfigured IAM grant, may. + */ +export function isInvalidEntitlementRequest(error: unknown): boolean { + return error instanceof Error && error.name === 'InvalidParameterException' +} + +/** Every entitlement AWS holds for one license of our product, across all pages. */ +async function fetchLicenseEntitlements( + productCode: string, + licenseArn: string, + signal?: AbortSignal +): Promise { + const entitlements: Entitlement[] = [] + let nextToken: string | undefined + for (let page = 0; page < MAX_PAGES; page++) { + const result = await getEntitlementClient().send( + new GetEntitlementsCommand({ + ProductCode: productCode, + Filter: { LICENSE_ARN: [licenseArn] }, + NextToken: nextToken, + }), + { abortSignal: signal } + ) + entitlements.push(...(result.Entitlements ?? [])) + nextToken = result.NextToken + if (!nextToken) return entitlements + } + throw new Error(`GetEntitlements returned more than ${MAX_PAGES} pages for one license`) +} + +/** Reads and summarizes one license's entitlements as of now; `signal` bounds the whole read. */ +export async function verifyLicense( + productCode: string, + licenseArn: string, + signal?: AbortSignal +): Promise { + const entitlements = await fetchLicenseEntitlements(productCode, licenseArn, signal) + return summarizeEntitlements(entitlements, new Date()) +} diff --git a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts new file mode 100644 index 00000000000..db0d1d85e7c --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts @@ -0,0 +1,1678 @@ +import type { Entitlement } from '@aws-sdk/client-marketplace-entitlement-service' +import { db } from '@sim/db' +import { + auditLog, + awsMarketplaceLicense, + member, + organization, + outboxEvent, + user, +} from '@sim/db/schema' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' +import { requestUtilsMock, requestUtilsMockFns } from '@sim/testing/mocks/request.mock' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, inArray } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const PRODUCT = 'product-placeholder' +/** New SaaS products: ResolveCustomer names the buyer by account ID, never `CustomerIdentifier`. */ +const ACCOUNT = '000000000000' +const AGREEMENT = 'agmt-placeholder' +const CONTACT = { contactEmail: 'buyer@example.test', companyName: 'Example Co' } + +const { meteringSend, entitlementSend, queueSend } = vi.hoisted(() => { + process.env.AWS_MARKETPLACE_PRODUCT_CODE = 'product-placeholder' + process.env.AWS_MARKETPLACE_EVENTS_QUEUE_URL = + 'https://queue.example.test/aws-marketplace-placeholder' + process.env.ADMIN_API_KEY = 'admin-key-placeholder' + return { meteringSend: vi.fn(), entitlementSend: vi.fn(), queueSend: vi.fn() } +}) + +vi.mock('@aws-sdk/client-marketplace-metering', () => ({ + MarketplaceMeteringClient: class { + send = meteringSend + }, + ResolveCustomerCommand: class { + constructor(public input: { RegistrationToken: string }) {} + }, +})) +vi.mock('@aws-sdk/client-marketplace-entitlement-service', () => ({ + MarketplaceEntitlementServiceClient: class { + send = entitlementSend + }, + GetEntitlementsCommand: class { + constructor(public input: { Filter: { LICENSE_ARN: string[] } }) {} + }, +})) +vi.mock('@aws-sdk/client-sqs', () => ({ + SQSClient: class { + send = queueSend + }, + ReceiveMessageCommand: class { + readonly kind = 'receive' + constructor(public input: unknown) {} + }, + DeleteMessageCommand: class { + readonly kind = 'delete' + constructor(public input: { ReceiptHandle: string }) {} + }, + ChangeMessageVisibilityCommand: class { + readonly kind = 'extend' + constructor(public input: { ReceiptHandle: string }) {} + }, +})) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) +vi.mock('@/lib/core/utils/request', () => requestUtilsMock) + +import { + linkAwsMarketplaceLicense, + listAccountAwsMarketplaceLicenses, + listOrganizationAwsMarketplaceLicenses, + readAwsMarketplaceClaim, + registerAwsMarketplaceLicenseToAccount, + saveAwsMarketplaceClaimContact, +} from '@/lib/billing/aws-marketplace/application/use-cases' +import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim' +import { + AWS_MARKETPLACE_BUYER_NOTICE_EVENT, + AWS_MARKETPLACE_TEAM_NOTICE_EVENT, +} from '@/lib/billing/aws-marketplace/notice-events' +import { awsMarketplaceOutboxHandlers } from '@/lib/billing/aws-marketplace/notifications' +import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile' +import { processOutboxEventById } from '@/lib/core/outbox/service' +import { POST as register, GET as revisit } from '@/app/api/marketplace/aws/register/route' +import { + POST as adminLink, + GET as adminList, +} from '@/app/api/v1/admin/dashboard/aws-marketplace-licenses/route' + +const NOTICE_EVENTS = [AWS_MARKETPLACE_TEAM_NOTICE_EVENT, AWS_MARKETPLACE_BUYER_NOTICE_EVENT] +const DAY_MS = 24 * 60 * 60 * 1000 +const TEAM = /^enterprise@/ +const BUYER = /^buyer@example\.test$/ +const CANCEL_CALL = /POST \/api\/v1\/admin\/dashboard\/organizations\/\/cancellation/ + +/** What GetEntitlements answers for each license ARN; an Error is thrown instead. */ +const entitlementsByLicense = new Map() +const licenseArns: string[] = [] +const userIds: string[] = [] +const organizationIds: string[] = [] + +function uniqueArn(): string { + const arn = `license-${generateId()}` + licenseArns.push(arn) + return arn +} + +function uniqueAccount(): string { + return generateId().replace(/\D/g, '').padEnd(12, '7').slice(0, 12) +} + +function uniqueAgreement(): string { + return `agmt-${generateId()}` +} + +function currentEntitlement(overrides: Partial = {}): Entitlement { + return { + ProductCode: PRODUCT, + Dimension: 'enterprise', + CustomerAWSAccountId: ACCOUNT, + Value: { IntegerValue: 25 }, + ExpirationDate: new Date(Date.now() + 365 * DAY_MS), + ...overrides, + } +} + +function lapsedEntitlement(): Entitlement { + return currentEntitlement({ ExpirationDate: new Date(Date.now() - 60_000) }) +} + +function throttled(): Error { + return Object.assign(new Error('Rate exceeded'), { + name: 'ThrottlingException', + $metadata: { httpStatusCode: 400 }, + }) +} + +function registration(body: string, contentType = 'application/x-www-form-urlencoded') { + requestUtilsMockFns.mockGetClientIp.mockReturnValue(`198.51.100.${licenseArns.length % 250}`) + return register( + new NextRequest('http://localhost:3000/api/marketplace/aws/register', { + method: 'POST', + headers: { 'content-type': contentType }, + body, + }), + {} + ) +} + +function resolvesTo(licenseArn: string, productCode = PRODUCT) { + meteringSend.mockResolvedValueOnce({ + CustomerAWSAccountId: ACCOUNT, + ProductCode: productCode, + LicenseArn: licenseArn, + Metadata: { AgreementId: AGREEMENT }, + }) +} + +function errorOf(response: Response): string | null { + return new URL(response.headers.get('location') ?? '').searchParams.get('error') +} + +function claimTokenOf(response: Response): string | null { + return ( + /sim_aws_marketplace_claim=([^;]+)/.exec(response.headers.get('set-cookie') ?? '')?.[1] ?? null + ) +} + +async function readLicense(licenseArn: string) { + const [row] = await db + .select() + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.licenseArn, licenseArn)) + return row +} + +async function createUser(): Promise { + const id = generateId() + userIds.push(id) + await db.insert(user).values({ + id, + name: 'Marketplace fixture', + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + return id +} + +async function createOrganization(members: Array<{ userId: string; role: string }>) { + const id = generateId() + organizationIds.push(id) + await db.insert(organization).values({ + id, + name: 'Marketplace organization', + slug: id, + createdAt: new Date(), + }) + await db.insert(member).values( + members.map((entry) => ({ + id: generateId(), + organizationId: id, + ...entry, + createdAt: new Date(), + })) + ) + return id +} + +async function insertLicense(overrides: Partial = {}) { + const licenseArn = overrides.licenseArn ?? uniqueArn() + const id = generateId() + const lastVerifiedAt = overrides.lastVerifiedAt ?? new Date() + await db.insert(awsMarketplaceLicense).values({ + id, + licenseArn, + productCode: PRODUCT, + customerAwsAccountId: ACCOUNT, + status: 'active', + dimension: 'enterprise', + quantity: 25, + lastVerifiedAt, + lastVerificationAttemptAt: lastVerifiedAt, + ...overrides, + }) + return { id, licenseArn } +} + +/** A registered, active license whose claim `token` this browser holds. */ +async function createClaimedLicense( + overrides: Partial = {} +) { + const token = generateId() + const license = await insertLicense({ + claimTokenHash: hashClaimToken(token), + claimExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + ...overrides, + }) + return { ...license, token } +} + +async function pendingNoticeIds(): Promise { + const rows = await db + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(and(inArray(outboxEvent.eventType, NOTICE_EVENTS), eq(outboxEvent.status, 'pending'))) + .orderBy(asc(outboxEvent.createdAt)) + return rows.map((row) => row.id) +} + +interface SentEmail { + to: string[] + subject: string + text: string +} + +/** Runs every queued notice through the outbox handlers, then reads what reached the mailer. */ +async function emailsTo(to: RegExp): Promise { + for (const id of await pendingNoticeIds()) { + await processOutboxEventById(id, awsMarketplaceOutboxHandlers) + } + return emailMailerMockFns.mockSendEmail.mock.calls + .map(([email]) => email as SentEmail) + .filter((email) => email.to.some((address) => to.test(address))) +} + +function rejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('Expected the operation to be refused') + }, + (error: unknown) => error + ) +} + +/** One batch of `events`, then an empty queue; returns the receipt handles deleted. */ +function deliverOnce(events: Array>) { + const deleted: string[] = [] + let delivered = false + queueSend.mockImplementation( + async (command: { kind: string; input: { ReceiptHandle: string } }) => { + if (command.kind === 'delete') { + deleted.push(command.input.ReceiptHandle) + return {} + } + if (command.kind === 'extend') return {} + if (delivered) return { Messages: [] } + delivered = true + return { + Messages: events.map((event, index) => ({ + MessageId: `m-${generateId()}`, + ReceiptHandle: `r-${index}`, + Body: JSON.stringify({ source: 'aws.agreement-marketplace', ...event }), + })), + } + } + ) + return deleted +} + +function licenseUpdated(licenseArn: string, agreementId?: string, time?: Date) { + return { + 'detail-type': 'License Updated - Manufacturer', + ...(time && { time: time.toISOString() }), + detail: { + license: { arn: licenseArn }, + product: { code: PRODUCT }, + ...(agreementId && { agreement: { id: agreementId } }), + }, + } +} + +function agreementCreated(agreementId: string, previousAgreementId?: string) { + return { + 'detail-type': 'Purchase Agreement Created - Proposer', + detail: { agreement: { id: agreementId, previousAgreementId } }, + } +} + +beforeAll(() => { + entitlementSend.mockImplementation( + async (command: { input: { Filter: { LICENSE_ARN: string[] } } }) => { + const answer = entitlementsByLicense.get(command.input.Filter.LICENSE_ARN[0]) + if (answer instanceof Error) throw answer + return { Entitlements: answer ?? [] } + } + ) +}) + +beforeEach(async () => { + meteringSend.mockReset() + queueSend.mockReset() + entitlementSend.mockClear() + emailMailerMockFns.mockSendEmail.mockReset() + emailMailerMockFns.mockSendEmail.mockResolvedValue({ success: true, message: 'sent' }) + await db.delete(outboxEvent).where(inArray(outboxEvent.eventType, NOTICE_EVENTS)) +}) + +afterAll(async () => { + await db.delete(outboxEvent).where(inArray(outboxEvent.eventType, NOTICE_EVENTS)) + await db + .delete(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.licenseArn, licenseArns)) + if (organizationIds.length) { + await db.delete(organization).where(inArray(organization.id, organizationIds)) + } + if (userIds.length) { + await db.delete(auditLog).where(inArray(auditLog.actorId, userIds)) + await db.delete(user).where(inArray(user.id, userIds)) + } +}) + +describe('POST /api/marketplace/aws/register', () => { + it('rejects a form without a registration token before calling AWS', async () => { + const response = await registration('unrelated=1') + + expect(response.status).toBe(303) + expect(errorOf(response)).toBe('invalid') + expect(response.headers.get('set-cookie')).toBeNull() + expect(meteringSend).not.toHaveBeenCalled() + }) + + it('answers a rate-limited browser with a redirect, not raw JSON', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + + const limited = await register( + new NextRequest('http://localhost:3000/api/marketplace/aws/register', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'x-amzn-marketplace-token=any', + }), + {} + ) + + expect(limited.status).toBe(303) + expect(errorOf(limited)).toBe('unavailable') + }) + + it('rejects a token that resolves to another product without recording it', async () => { + const licenseArn = uniqueArn() + resolvesTo(licenseArn, 'other-product') + + const response = await registration('x-amzn-marketplace-token=opaque%2Btoken') + + expect(errorOf(response)).toBe('invalid') + expect(response.headers.get('set-cookie')).toBeNull() + expect(await readLicense(licenseArn)).toBeUndefined() + expect(meteringSend.mock.calls[0][0].input.RegistrationToken).toBe('opaque+token') + }) + + it('records a lapsed license as inactive and issues no claim', async () => { + const licenseArn = uniqueArn() + resolvesTo(licenseArn) + entitlementsByLicense.set(licenseArn, [lapsedEntitlement()]) + + const response = await registration('x-amzn-marketplace-token=lapsed-token') + + expect(errorOf(response)).toBe('no-subscription') + expect(response.headers.get('set-cookie')).toBeNull() + expect(await readLicense(licenseArn)).toMatchObject({ + status: 'inactive', + claimTokenHash: null, + claimExpiresAt: null, + }) + }) + + it('issues an httpOnly four-hour claim for an active license and stores only its hash', async () => { + const licenseArn = uniqueArn() + resolvesTo(licenseArn) + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + + const response = await registration('x-amzn-marketplace-token=active-token') + + const location = new URL(response.headers.get('location') ?? '') + expect(response.status).toBe(303) + expect(location.pathname).toBe('/marketplace/aws') + expect(location.searchParams.get('error')).toBeNull() + const cookie = response.headers.get('set-cookie') ?? '' + expect(cookie).toMatch(/HttpOnly/) + expect(cookie).toMatch(/Secure/) + expect(cookie).toMatch(/SameSite=Lax/) + const token = claimTokenOf(response) ?? '' + expect(token.length).toBeGreaterThan(20) + const row = await readLicense(licenseArn) + expect(row).toMatchObject({ + status: 'active', + quantity: 25, + claimTokenHash: hashClaimToken(token), + }) + const remaining = (row?.claimExpiresAt?.getTime() ?? 0) - Date.now() + expect(remaining).toBeGreaterThan(3.9 * 60 * 60 * 1000) + expect(remaining).toBeLessThanOrEqual(4 * 60 * 60 * 1000) + }) + + it('registers a new-product buyer named by account ID alone, and tells the team at once', async () => { + const licenseArn = uniqueArn() + resolvesTo(licenseArn) + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + + const response = await registration('x-amzn-marketplace-token=new-product-token') + + expect(claimTokenOf(response)).not.toBeNull() + expect(await readLicense(licenseArn)).toMatchObject({ + status: 'active', + customerAwsAccountId: ACCOUNT, + agreementId: AGREEMENT, + }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ + subject: expect.stringMatching(/New license/), + text: expect.stringMatching(/may not finish it[\s\S]*not received yet/), + }), + ]) + }) + + it('does not let an older read restore a license a newer read lapsed', async () => { + const { licenseArn } = await insertLicense({ + status: 'inactive', + lastVerifiedAt: new Date(Date.now() + 5 * 60 * 1000), + }) + resolvesTo(licenseArn) + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + + const response = await registration('x-amzn-marketplace-token=slow-token') + + expect(errorOf(response)).toBe('no-subscription') + expect(response.headers.get('set-cookie')).toBeNull() + expect(await readLicense(licenseArn)).toMatchObject({ + status: 'inactive', + claimTokenHash: null, + }) + }) + + it('tells the team when a registration read moves an existing license’s terms', async () => { + const { licenseArn } = await insertLicense({ quantity: 10 }) + resolvesTo(licenseArn) + entitlementsByLicense.set(licenseArn, [currentEntitlement({ Value: { IntegerValue: 40 } })]) + + await registration('x-amzn-marketplace-token=moved-token') + + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/quantity 10[\s\S]*quantity 40/) }), + ]) + }) + + it('sends a buyer whose agreement has not started back on its start date, saving nothing', async () => { + const account = uniqueAccount() + meteringSend.mockResolvedValueOnce({ CustomerAWSAccountId: account, ProductCode: PRODUCT }) + + const response = await registration('x-amzn-marketplace-token=future-dated-token') + + expect(errorOf(response)).toBe('not-started') + expect(response.headers.get('set-cookie')).toBeNull() + expect(entitlementSend).not.toHaveBeenCalled() + expect( + await db + .select({ id: awsMarketplaceLicense.id }) + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.customerAwsAccountId, account)) + ).toEqual([]) + }) + + it('sends a refreshed or bookmarked fulfillment URL to the onboarding page', async () => { + const response = await revisit( + new NextRequest('http://localhost:3000/api/marketplace/aws/register'), + {} + ) + + expect(response.status).toBe(303) + expect(new URL(response.headers.get('location') ?? '').pathname).toBe('/marketplace/aws') + expect(response.headers.get('set-cookie')).toBeNull() + }) +}) + +describe('a license AWS has not confirmed yet', () => { + it('holds a claim, takes contact details, refuses a take, and proceeds once AWS confirms', async () => { + const buyerId = await createUser() + const principal = createSessionPrincipal({ userId: buyerId }) + const licenseArn = uniqueArn() + resolvesTo(licenseArn) + entitlementsByLicense.set(licenseArn, []) + + const response = await registration('x-amzn-marketplace-token=early-token') + + const claimToken = claimTokenOf(response) + expect(claimToken).not.toBeNull() + const row = await readLicense(licenseArn) + expect(row).toMatchObject({ status: 'pending', linkedByUserId: null }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/Unconfirmed/) }), + ]) + + expect( + await readAwsMarketplaceClaim.execute({ principal, input: { claimToken } }) + ).toMatchObject({ status: 'pending' }) + const refused = await rejection( + registerAwsMarketplaceLicenseToAccount.execute({ + principal, + input: { ...CONTACT, licenseId: row.id, claimToken }, + }) + ) + expect(refused).toMatchObject({ + code: 'conflict', + message: expect.stringMatching(/still confirming/), + }) + expect((await readLicense(licenseArn))?.claimTokenHash).not.toBeNull() + + expect( + await saveAwsMarketplaceClaimContact.execute({ + principal, + input: { ...CONTACT, licenseId: row.id, claimToken }, + }) + ).toEqual({ status: 'pending' }) + expect(await readLicense(licenseArn)).toMatchObject({ + contactEmail: CONTACT.contactEmail, + linkedByUserId: null, + }) + await emailsTo(TEAM) + + emailMailerMockFns.mockSendEmail.mockClear() + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + expect( + await saveAwsMarketplaceClaimContact.execute({ + principal, + input: { ...CONTACT, licenseId: row.id, claimToken }, + }) + ).toEqual({ status: 'active' }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ subject: expect.stringMatching(/pending → active/) }), + ]) + await registerAwsMarketplaceLicenseToAccount.execute({ + principal, + input: { ...CONTACT, licenseId: row.id, claimToken }, + }) + expect((await readLicense(licenseArn))?.linkedByUserId).toBe(buyerId) + }) + + it('stays pending on a reverify, but a license once confirmed lapses', async () => { + const pending = await insertLicense({ + status: 'pending', + lastVerifiedAt: new Date(Date.now() - 2 * DAY_MS), + }) + const confirmed = await insertLicense({ + claimTokenHash: hashClaimToken(generateId()), + claimExpiresAt: new Date(Date.now() + 60_000), + lastVerifiedAt: new Date(Date.now() - 2 * DAY_MS), + }) + entitlementsByLicense.set(pending.licenseArn, []) + entitlementsByLicense.set(confirmed.licenseArn, []) + deliverOnce([]) + + await reconcileAwsMarketplaceLicenses() + + expect((await readLicense(pending.licenseArn))?.status).toBe('pending') + expect(await readLicense(confirmed.licenseArn)).toMatchObject({ + status: 'inactive', + claimTokenHash: null, + }) + }) +}) + +describe('reading a claim', () => { + it('shows a masked account, and says who holds a linked license only to its administrators', async () => { + const adminId = await createUser() + const strangerId = await createUser() + const organizationId = await createOrganization([{ userId: adminId, role: 'admin' }]) + const open = await createClaimedLicense({ customerAwsAccountId: '123456781234' }) + const linked = await createClaimedLicense({ organizationId }) + const expired = await createClaimedLicense({ claimExpiresAt: new Date(Date.now() - 1000) }) + const read = (userId: string, claimToken: string) => + readAwsMarketplaceClaim.execute({ + principal: createSessionPrincipal({ userId }), + input: { claimToken }, + }) + + expect(await read(strangerId, open.token)).toMatchObject({ + status: 'ready', + license: { awsAccount: '••••1234' }, + }) + expect(await read(adminId, linked.token)).toEqual({ status: 'linked', organizationId }) + expect(await read(strangerId, linked.token)).toEqual({ status: 'linked', organizationId: null }) + expect(await read(strangerId, expired.token)).toEqual({ status: 'missing' }) + }) +}) + +describe('reading subscription status', () => { + it('shows members only the licenses linked to their organization and refuses others', async () => { + const memberId = await createUser() + const outsiderId = await createUser() + const organizationId = await createOrganization([{ userId: memberId, role: 'member' }]) + const otherOrganizationId = await createOrganization([{ userId: outsiderId, role: 'owner' }]) + const ours = await insertLicense({ organizationId }) + await insertLicense({ organizationId: otherOrganizationId }) + + const licenses = await listOrganizationAwsMarketplaceLicenses.execute({ + principal: createSessionPrincipal({ userId: memberId }), + input: { organizationId }, + }) + const refusal = await rejection( + listOrganizationAwsMarketplaceLicenses.execute({ + principal: createSessionPrincipal({ userId: outsiderId }), + input: { organizationId }, + }) + ) + + expect(licenses).toEqual([expect.objectContaining({ id: ours.id, status: 'active' })]) + expect(refusal).toMatchObject({ code: 'not_found' }) + }) + + it('shows a buyer the licenses registered to their account alone', async () => { + const buyerId = await createUser() + const otherId = await createUser() + const organizationId = await createOrganization([{ userId: buyerId, role: 'owner' }]) + const registered = await insertLicense({ linkedByUserId: buyerId }) + await insertLicense({ linkedByUserId: buyerId, organizationId }) + await insertLicense({ linkedByUserId: otherId }) + + const licenses = await listAccountAwsMarketplaceLicenses.execute({ + principal: createSessionPrincipal({ userId: buyerId }), + input: {}, + }) + + expect(licenses).toEqual([expect.objectContaining({ id: registered.id })]) + }) +}) + +describe('linking a claimed license', () => { + let ownerId: string + let memberId: string + let organizationId: string + + beforeAll(async () => { + ownerId = await createUser() + memberId = await createUser() + organizationId = await createOrganization([ + { userId: ownerId, role: 'owner' }, + { userId: memberId, role: 'member' }, + ]) + }) + + function link(userId: string, input: { licenseId: string; claimToken: string | null }) { + return linkAwsMarketplaceLicense.execute({ + principal: createSessionPrincipal({ userId }), + input: { ...CONTACT, organizationId, ...input }, + }) + } + + it('refuses an organization member who is not an owner or admin', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + + const error = await rejection( + link(memberId, { licenseId: license.id, claimToken: license.token }) + ) + + expect(error).toMatchObject({ code: 'forbidden', detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId: null, + claimTokenHash: hashClaimToken(license.token), + }) + }) + + it('refuses to move a license already linked to another organization', async () => { + const otherOwnerId = await createUser() + const otherOrganizationId = await createOrganization([{ userId: otherOwnerId, role: 'owner' }]) + const license = await createClaimedLicense({ organizationId: otherOrganizationId }) + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + + const error = await rejection( + link(ownerId, { licenseId: license.id, claimToken: license.token }) + ) + + expect(error).toMatchObject({ code: 'conflict' }) + expect((await readLicense(license.licenseArn))?.organizationId).toBe(otherOrganizationId) + }) + + it('links under a newer active read without overwriting it with its own older one', async () => { + const license = await createClaimedLicense({ lastVerifiedAt: new Date(Date.now() + 60_000) }) + entitlementsByLicense.set(license.licenseArn, [ + currentEntitlement({ Value: { IntegerValue: 40 } }), + ]) + + await link(ownerId, { licenseId: license.id, claimToken: license.token }) + + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId, + quantity: 25, + claimTokenHash: null, + }) + }) + + it('takes a claim at most once when two tabs race it on Postgres', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [ + currentEntitlement({ ExpirationDate: undefined }), + ]) + const take = () => link(ownerId, { licenseId: license.id, claimToken: license.token }) + + const results = await Promise.allSettled([take(), take()]) + + expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']) + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId, + claimTokenHash: null, + }) + expect(await emailsTo(TEAM)).toHaveLength(1) + expect(await emailsTo(BUYER)).toHaveLength(1) + }) + + it('links once, consumes the claim, audits it, and queues both notices with their terms', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [ + currentEntitlement({ Value: { IntegerValue: 40 } }), + ]) + + await link(ownerId, { licenseId: license.id, claimToken: license.token }) + + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId, + linkedByUserId: ownerId, + quantity: 40, + claimTokenHash: null, + claimExpiresAt: null, + contactEmail: CONTACT.contactEmail, + companyName: CONTACT.companyName, + }) + const team = await emailsTo(TEAM) + expect(team.map((email) => email.subject)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/active → active/), + expect.stringMatching(/Linked to an organization/), + ]) + ) + const linkNotice = team.find((email) => /Linked to an organization/.test(email.subject)) + expect(linkNotice?.text).toMatch(/Company: Example Co/) + expect(linkNotice?.text).toMatch(/pausePaymentCollection: true/) + expect(linkNotice?.text).toMatch(/Never send a Stripe invoice/) + expect(await emailsTo(BUYER)).toEqual([ + expect.objectContaining({ + text: expect.stringMatching( + /being set up\. Please wait for us to contact you\. We expect to finish within 2 business days\.[\s\S]*Questions\? Contact \S+@\S+/ + ), + }), + ]) + await vi.waitFor(async () => { + const entries = await db + .select({ action: auditLog.action }) + .from(auditLog) + .where(and(eq(auditLog.resourceId, license.id), eq(auditLog.actorId, ownerId))) + expect(entries).toEqual([{ action: 'aws_marketplace_license.linked' }]) + }) + }) +}) + +describe.each([ + { + take: 'linking', + run: async (input: { licenseId: string; claimToken: string | null }) => { + const userId = await createUser() + const organizationId = await createOrganization([{ userId, role: 'owner' }]) + return linkAwsMarketplaceLicense.execute({ + principal: createSessionPrincipal({ userId }), + input: { ...CONTACT, organizationId, ...input }, + }) + }, + }, + { + take: 'registering', + run: async (input: { licenseId: string; claimToken: string | null }) => + registerAwsMarketplaceLicenseToAccount.execute({ + principal: createSessionPrincipal({ userId: await createUser() }), + input: { ...CONTACT, ...input }, + }), + }, +])('$take refuses', ({ run }) => { + it('a browser that holds no claim', async () => { + const license = await createClaimedLicense() + + const error = await rejection(run({ licenseId: license.id, claimToken: null })) + + expect(error).toMatchObject({ code: 'forbidden' }) + expect((await readLicense(license.licenseArn))?.linkedByUserId).toBeNull() + }) + + it('an expired claim', async () => { + const license = await createClaimedLicense({ claimExpiresAt: new Date(Date.now() - 1000) }) + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + + const error = await rejection(run({ licenseId: license.id, claimToken: license.token })) + + expect(error).toMatchObject({ code: 'forbidden', message: expect.stringMatching(/expired/) }) + expect((await readLicense(license.licenseArn))?.linkedByUserId).toBeNull() + }) + + it('a lapsed license, clearing its claim and telling only the team', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [lapsedEntitlement()]) + + const error = await rejection(run({ licenseId: license.id, claimToken: license.token })) + + expect(error).toMatchObject({ code: 'conflict' }) + expect(await readLicense(license.licenseArn)).toMatchObject({ + linkedByUserId: null, + status: 'inactive', + claimTokenHash: null, + }) + expect(await emailsTo(BUYER)).toEqual([]) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ subject: expect.stringMatching(/active → inactive/) }), + ]) + }) + + it('an active read older than a newer read that still says pending', async () => { + const license = await createClaimedLicense({ + status: 'pending', + lastVerifiedAt: new Date(Date.now() + 5 * 60 * 1000), + }) + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + + const error = await rejection(run({ licenseId: license.id, claimToken: license.token })) + + expect(error).toMatchObject({ code: 'conflict', message: expect.stringMatching(/confirming/) }) + expect(await readLicense(license.licenseArn)).toMatchObject({ + linkedByUserId: null, + status: 'pending', + claimTokenHash: hashClaimToken(license.token), + }) + expect(await emailsTo(/./)).toEqual([]) + }) +}) + +describe('registering a claimed license to an account', () => { + let buyerId: string + + beforeAll(async () => { + buyerId = await createUser() + }) + + function registerToAccount(input: { + licenseId: string + claimToken: string | null + contactEmail?: string + }) { + return registerAwsMarketplaceLicenseToAccount.execute({ + principal: createSessionPrincipal({ userId: buyerId }), + input: { ...CONTACT, ...input }, + }) + } + + it('registers to a buyer with no organization, consumes the claim, audits, and tells both', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [ + currentEntitlement({ ExpirationDate: undefined }), + ]) + + await registerToAccount({ licenseId: license.id, claimToken: license.token }) + + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId: null, + linkedByUserId: buyerId, + claimTokenHash: null, + claimExpiresAt: null, + }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/no organization yet/) }), + ]) + expect(await emailsTo(BUYER)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/within 2 business days/) }), + ]) + await vi.waitFor(async () => { + const entries = await db + .select({ action: auditLog.action }) + .from(auditLog) + .where(and(eq(auditLog.resourceId, license.id), eq(auditLog.actorId, buyerId))) + expect(entries).toEqual([{ action: 'aws_marketplace_license.registered' }]) + }) + }) + + it('tells the team when a re-registration changes the contact details, and only then', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + await registerToAccount({ licenseId: license.id, claimToken: license.token }) + const contactNotices = async () => + (await emailsTo(TEAM)).filter((email) => /Buyer contact/.test(email.subject)) + const reclaim = async (token: string) => { + resolvesTo(license.licenseArn) + return claimTokenOf(await registration(`x-amzn-marketplace-token=${token}`)) + } + + await registerToAccount({ licenseId: license.id, claimToken: await reclaim('same-contact') }) + expect(await contactNotices()).toEqual([]) + + await registerToAccount({ + licenseId: license.id, + claimToken: await reclaim('new-contact'), + contactEmail: 'new-contact@example.test', + }) + + expect(await contactNotices()).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/new-contact@example\.test/) }), + ]) + expect((await readLicense(license.licenseArn))?.contactEmail).toBe('new-contact@example.test') + }) + + it('refuses a license already linked to an organization', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization([{ userId: ownerId, role: 'owner' }]) + const license = await createClaimedLicense({ organizationId, linkedByUserId: ownerId }) + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + + const error = await rejection( + registerToAccount({ licenseId: license.id, claimToken: license.token }) + ) + + expect(error).toMatchObject({ code: 'conflict' }) + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId, + linkedByUserId: ownerId, + }) + }) + + it('lets a license registered to an account be linked to an organization later', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + await registerToAccount({ licenseId: license.id, claimToken: license.token }) + + resolvesTo(license.licenseArn) + const response = await registration('x-amzn-marketplace-token=second-visit') + const token = claimTokenOf(response) + expect(token).not.toBeNull() + const organizationId = await createOrganization([{ userId: buyerId, role: 'owner' }]) + + await linkAwsMarketplaceLicense.execute({ + principal: createSessionPrincipal({ userId: buyerId }), + input: { ...CONTACT, organizationId, licenseId: license.id, claimToken: token }, + }) + + expect(await readLicense(license.licenseArn)).toMatchObject({ + organizationId, + linkedByUserId: buyerId, + claimTokenHash: null, + }) + }) +}) + +describe('notice delivery', () => { + it('retries a failed send through the outbox without losing the notice', async () => { + const license = await createClaimedLicense() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + await registerAwsMarketplaceLicenseToAccount.execute({ + principal: createSessionPrincipal({ userId: await createUser() }), + input: { ...CONTACT, licenseId: license.id, claimToken: license.token }, + }) + emailMailerMockFns.mockSendEmail.mockResolvedValueOnce({ success: false, message: 'down' }) + const [teamNotice] = await pendingNoticeIds() + + expect(await processOutboxEventById(teamNotice, awsMarketplaceOutboxHandlers)).toBe('pending') + await db + .update(outboxEvent) + .set({ availableAt: new Date(Date.now() - 1000) }) + .where(eq(outboxEvent.id, teamNotice)) + expect(await processOutboxEventById(teamNotice, awsMarketplaceOutboxHandlers)).toBe('completed') + + expect(emailMailerMockFns.mockSendEmail).toHaveBeenCalledTimes(2) + }) +}) + +describe('reconcileAwsMarketplaceLicenses', () => { + it('discards poison events, keeps a throttled one for redelivery, and marks lapsed licenses inactive', async () => { + const event = await insertLicense({ lastVerifiedAt: new Date(Date.now() - 60_000) }) + const throttledArn = uniqueArn() + const stale = await insertLicense({ lastVerifiedAt: new Date(Date.now() - 2 * DAY_MS) }) + entitlementsByLicense.set(event.licenseArn, [lapsedEntitlement()]) + entitlementsByLicense.set(throttledArn, throttled()) + entitlementsByLicense.set(stale.licenseArn, []) + const deprovisioned = (arn: string, productCode = PRODUCT) => ({ + 'detail-type': 'License Deprovisioned - Manufacturer', + detail: { license: { arn }, product: { code: productCode } }, + }) + const deleted = deliverOnce([ + { source: 'not-marketplace' }, + deprovisioned(event.licenseArn, 'other'), + deprovisioned(throttledArn), + deprovisioned(event.licenseArn), + ]) + + const summary = await reconcileAwsMarketplaceLicenses() + + expect(deleted.sort()).toEqual(['r-0', 'r-1', 'r-3']) + expect(summary).toMatchObject({ received: 4, applied: 1, discarded: 2, retained: 1 }) + expect((await readLicense(event.licenseArn))?.status).toBe('inactive') + expect(await readLicense(throttledArn)).toBeUndefined() + const staleRow = await readLicense(stale.licenseArn) + expect(staleRow).toMatchObject({ status: 'inactive', dimension: 'enterprise' }) + expect(staleRow?.lastVerifiedAt.getTime()).toBeGreaterThan(Date.now() - 60_000) + const notices = await emailsTo(TEAM) + expect(notices.map((email) => email.subject)).toEqual([ + expect.stringMatching(/active → inactive/), + expect.stringMatching(/active → inactive/), + ]) + }) + + it('gives the cancel instruction and its required body only when an organization holds the lapsed license', async () => { + const organizationId = await createOrganization([{ userId: await createUser(), role: 'owner' }]) + const linked = await insertLicense({ organizationId }) + const unlinked = await insertLicense() + entitlementsByLicense.set(linked.licenseArn, [lapsedEntitlement()]) + entitlementsByLicense.set(unlinked.licenseArn, [lapsedEntitlement()]) + deliverOnce( + [linked, unlinked].map(({ licenseArn }) => ({ + 'detail-type': 'License Deprovisioned - Manufacturer', + detail: { license: { arn: licenseArn }, product: { code: PRODUCT } }, + })) + ) + + await reconcileAwsMarketplaceLicenses() + + const notices = await emailsTo(TEAM) + const noticeOf = ({ id }: { id: string }) => + notices.find((email) => email.text.includes(`License ID: ${id}`))?.text + expect(noticeOf(linked)).toMatch(CANCEL_CALL) + expect(noticeOf(linked)).toMatch(/\(operationId: a new UUID, timing: immediate\)/) + expect(noticeOf(unlinked)).not.toMatch(CANCEL_CALL) + expect(noticeOf(unlinked)).toMatch( + /No organization holds this license\. If you provisioned one for this buyer, cancel it there\./ + ) + }) + + it('does not tell the team to cancel when a retried notice arrives after the license recovered', async () => { + const organizationId = await createOrganization([{ userId: await createUser(), role: 'owner' }]) + const license = await insertLicense({ organizationId }) + const event = { + 'detail-type': 'License Updated - Manufacturer', + detail: { license: { arn: license.licenseArn }, product: { code: PRODUCT } }, + } + entitlementsByLicense.set(license.licenseArn, [lapsedEntitlement()]) + deliverOnce([event]) + await reconcileAwsMarketplaceLicenses() + entitlementsByLicense.set(license.licenseArn, [currentEntitlement()]) + deliverOnce([event]) + await reconcileAwsMarketplaceLicenses() + + const notices = await emailsTo(TEAM) + + expect(notices.length).toBeGreaterThan(1) + for (const notice of notices) expect(notice.text).not.toMatch(CANCEL_CALL) + }) + + it('does not ask the team to provision a license that lapsed before setup', async () => { + const licenseArn = uniqueArn() + entitlementsByLicense.set(licenseArn, [lapsedEntitlement()]) + deliverOnce([ + { + 'detail-type': 'License Updated - Manufacturer', + detail: { + license: { arn: licenseArn }, + product: { code: PRODUCT }, + acceptor: { accountId: ACCOUNT }, + }, + }, + ]) + + await reconcileAwsMarketplaceLicenses() + + const [notice] = await emailsTo(TEAM) + expect(notice.text).toMatch(/Do not provision this license/) + expect(notice.text).not.toMatch(/pausePaymentCollection/) + }) + + it('re-verifies only the configured product', async () => { + const longAgo = new Date(Date.now() - 3 * DAY_MS) + const other = await insertLicense({ + productCode: 'another-product', + lastVerifiedAt: longAgo, + lastVerificationAttemptAt: longAgo, + }) + entitlementsByLicense.set(other.licenseArn, [lapsedEntitlement()]) + queueSend.mockResolvedValue({ Messages: [] }) + + await reconcileAwsMarketplaceLicenses() + + expect((await readLicense(other.licenseArn))?.lastVerificationAttemptAt).toEqual(longAgo) + }) + + it('sends the team nothing when a re-verify finds the same terms', async () => { + const expiresAt = new Date(Date.now() + 30 * DAY_MS) + const { licenseArn } = await insertLicense({ + expiresAt, + lastVerifiedAt: new Date(Date.now() - 2 * DAY_MS), + }) + entitlementsByLicense.set(licenseArn, [currentEntitlement({ ExpirationDate: expiresAt })]) + deliverOnce([]) + + const summary = await reconcileAwsMarketplaceLicenses() + + expect(summary).toMatchObject({ reverified: 1 }) + expect((await readLicense(licenseArn))?.lastVerifiedAt.getTime()).toBeGreaterThan( + Date.now() - 60_000 + ) + expect(await emailsTo(TEAM)).toEqual([]) + }) + + it('tells the team when only the dimension changed', async () => { + const expiresAt = new Date(Date.now() + 30 * DAY_MS) + const { licenseArn } = await insertLicense({ + expiresAt, + lastVerifiedAt: new Date(Date.now() - 2 * DAY_MS), + }) + entitlementsByLicense.set(licenseArn, [ + currentEntitlement({ ExpirationDate: expiresAt, Dimension: 'premier' }), + ]) + deliverOnce([]) + + await reconcileAwsMarketplaceLicenses() + + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/After: active, dimension premier/) }), + ]) + }) + + it('stamps a License Updated event, which the provisioning notice then reports', async () => { + const { id, licenseArn } = await insertLicense() + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + deliverOnce([licenseUpdated(licenseArn)]) + + await reconcileAwsMarketplaceLicenses() + + const row = await readLicense(licenseArn) + expect(row?.licenseEventReceivedAt).toBeInstanceOf(Date) + expect(row?.id).toBe(id) + }) + + it('tells the team once when the License Updated event reaches a license already taken', async () => { + const organizationId = await createOrganization([{ userId: await createUser(), role: 'owner' }]) + const linked = await insertLicense({ organizationId }) + const registered = await insertLicense({ linkedByUserId: await createUser() }) + const unowned = await insertLicense() + for (const { licenseArn } of [linked, registered, unowned]) { + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + } + deliverOnce([ + licenseUpdated(linked.licenseArn), + licenseUpdated(registered.licenseArn), + licenseUpdated(unowned.licenseArn), + licenseUpdated(linked.licenseArn), + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 4, retained: 0 }) + + const received = (await emailsTo(TEAM)).filter((email) => + /License Updated event received/.test(email.subject) + ) + const announces = ({ id }: { id: string }) => + received.filter((email) => email.text.includes(`License ID: ${id}`)).length + expect(received).toHaveLength(2) + expect(announces(linked)).toBe(1) + expect(announces(registered)).toBe(1) + expect(announces(unowned)).toBe(0) + }) + + it('keeps a Deprovisioned event for redelivery while AWS still reports the license current', async () => { + const { licenseArn } = await insertLicense() + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + const deleted = deliverOnce([ + { + 'detail-type': 'License Deprovisioned - Manufacturer', + detail: { license: { arn: licenseArn }, product: { code: PRODUCT } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ retained: 1 }) + expect(deleted).toEqual([]) + }) + + it('leaves a license event with no ARN for the dead-letter queue', async () => { + const deleted = deliverOnce([ + { 'detail-type': 'License Updated - Manufacturer', detail: { product: { code: PRODUCT } } }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ retained: 1 }) + expect(deleted).toEqual([]) + }) + + it('keeps a License Updated event for redelivery while AWS describes nothing for an unknown license', async () => { + const licenseArn = uniqueArn() + entitlementsByLicense.set(licenseArn, []) + const deleted = deliverOnce([licenseUpdated(licenseArn)]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ + retained: 1, + skipped: 0, + applied: 0, + }) + expect(deleted).toEqual([]) + expect(await readLicense(licenseArn)).toBeUndefined() + }) + + it('skips and deletes a Deprovisioned event for a license it never recorded', async () => { + const licenseArn = uniqueArn() + entitlementsByLicense.set(licenseArn, []) + const deleted = deliverOnce([ + { + 'detail-type': 'License Deprovisioned - Manufacturer', + detail: { license: { arn: licenseArn }, product: { code: PRODUCT } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ skipped: 1, retained: 0 }) + expect(deleted).toEqual(['r-0']) + expect(await readLicense(licenseArn)).toBeUndefined() + }) + + it('records a license an event names that nobody registered and tells the team who may own it', async () => { + const account = uniqueAccount() + const ownerId = await createUser() + const organizationId = await createOrganization([{ userId: ownerId, role: 'owner' }]) + await insertLicense({ organizationId, customerAwsAccountId: account }) + const newArn = uniqueArn() + entitlementsByLicense.set(newArn, [ + currentEntitlement({ CustomerAWSAccountId: undefined, Value: { IntegerValue: 60 } }), + ]) + deliverOnce([ + { + ...licenseUpdated(newArn), + detail: { + license: { arn: newArn }, + product: { code: PRODUCT }, + acceptor: { accountId: account }, + }, + }, + ]) + + await reconcileAwsMarketplaceLicenses() + + expect(await readLicense(newArn)).toMatchObject({ + customerAwsAccountId: account, + organizationId: null, + status: 'active', + quantity: 60, + }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ + subject: expect.stringMatching(/New license/), + text: expect.stringContaining(`Marketplace organization (${organizationId})`), + }), + ]) + }) + + it('stops receiving batches once the run budget cannot fit another', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + queueSend.mockImplementation(async (command: { kind: string }) => { + if (command.kind !== 'receive') return {} + vi.setSystemTime(Date.now() + 60_000) + return { + Messages: [{ MessageId: generateId(), ReceiptHandle: generateId(), Body: '{not json' }], + } + }) + + const summary = await reconcileAwsMarketplaceLicenses() + + const receives = queueSend.mock.calls.filter(([command]) => command.kind === 'receive') + expect(summary?.outOfBudget).toBe(true) + expect(receives.length).toBeGreaterThan(0) + expect(receives.length).toBeLessThan(10) + } finally { + vi.useRealTimers() + } + }) + + it('leaves messages it has not started queued once the run budget cannot fit another', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const licenses = await Promise.all([insertLicense(), insertLicense()]) + for (const { licenseArn } of licenses) { + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + } + const deleted = deliverOnce( + licenses.map(({ licenseArn }) => ({ + 'detail-type': 'License Updated - Manufacturer', + detail: { license: { arn: licenseArn }, product: { code: PRODUCT } }, + })) + ) + const receive = queueSend.getMockImplementation() + queueSend.mockImplementation(async (command: { kind: string }) => { + const answer = await receive?.(command) + if (command.kind === 'receive') vi.setSystemTime(Date.now() + 6 * 60_000) + return answer + }) + + const summary = await reconcileAwsMarketplaceLicenses() + + expect(summary).toMatchObject({ received: 0, outOfBudget: true }) + expect(deleted).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('keeps licenses AWS keeps refusing from starving the rest of the re-verify', async () => { + const failing = Array.from({ length: 50 }, () => uniqueArn()) + const healthyArn = uniqueArn() + const longAgo = (days: number) => new Date(Date.now() - days * DAY_MS) + await db.insert(awsMarketplaceLicense).values([ + ...failing.map((licenseArn) => ({ + id: generateId(), + licenseArn, + productCode: PRODUCT, + customerAwsAccountId: ACCOUNT, + status: 'active' as const, + lastVerifiedAt: longAgo(3), + lastVerificationAttemptAt: longAgo(3), + })), + { + id: generateId(), + licenseArn: healthyArn, + productCode: PRODUCT, + customerAwsAccountId: ACCOUNT, + status: 'active' as const, + lastVerifiedAt: longAgo(2), + lastVerificationAttemptAt: longAgo(2), + }, + ]) + const rejected = Object.assign(new Error('Invalid license'), { + name: 'InvalidParameterException', + $metadata: { httpStatusCode: 400 }, + }) + for (const licenseArn of failing) entitlementsByLicense.set(licenseArn, rejected) + entitlementsByLicense.set(healthyArn, [currentEntitlement()]) + queueSend.mockResolvedValue({ Messages: [] }) + + const first = await reconcileAwsMarketplaceLicenses() + const second = await reconcileAwsMarketplaceLicenses() + + expect(first).toMatchObject({ reverified: 0, reverifyFailed: 50 }) + expect(second).toMatchObject({ reverified: 1, reverifyFailed: 0 }) + expect((await readLicense(healthyArn))?.lastVerifiedAt.getTime()).toBeGreaterThan( + Date.now() - 60_000 + ) + }) +}) + +describe('agreement events', () => { + it('re-verifies the license of an ended agreement, from an event that names no product', async () => { + const agreementId = uniqueAgreement() + const { licenseArn } = await insertLicense({ agreementId }) + entitlementsByLicense.set(licenseArn, []) + const deleted = deliverOnce([ + { + 'detail-type': 'Purchase Agreement Ended - Proposer', + detail: { agreement: { id: agreementId } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 }) + expect(deleted).toEqual(['r-0']) + expect((await readLicense(licenseArn))?.status).toBe('inactive') + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ subject: expect.stringMatching(/active → inactive/) }), + ]) + }) + + it('keeps an ended agreement queued while AWS still reports its license current', async () => { + const agreementId = uniqueAgreement() + const { licenseArn } = await insertLicense({ agreementId }) + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + const deleted = deliverOnce([ + { + 'detail-type': 'Purchase Agreement Ended - Proposer', + detail: { agreement: { id: agreementId } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ retained: 1, applied: 0 }) + expect(deleted).toEqual([]) + }) + + it('reads every license of an ended agreement even when one fails, and keeps the message', async () => { + const agreementId = uniqueAgreement() + const failing = await insertLicense({ agreementId, lastVerificationAttemptAt: new Date(0) }) + const healthy = await insertLicense({ agreementId }) + entitlementsByLicense.set(failing.licenseArn, throttled()) + entitlementsByLicense.set(healthy.licenseArn, []) + const deleted = deliverOnce([ + { + 'detail-type': 'Purchase Agreement Ended - Manufacturer', + detail: { agreement: { id: agreementId } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ retained: 1 }) + expect(deleted).toEqual([]) + expect((await readLicense(failing.licenseArn))?.status).toBe('active') + expect((await readLicense(healthy.licenseArn))?.status).toBe('inactive') + }) + + it('discards an ended agreement it holds no license for without asking AWS', async () => { + const deleted = deliverOnce([ + { + 'detail-type': 'Purchase Agreement Ended - Manufacturer', + detail: { agreement: { id: uniqueAgreement() }, product: { code: 'other-product' } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 0, discarded: 1 }) + expect(deleted).toEqual(['r-0']) + expect(entitlementSend).not.toHaveBeenCalled() + expect(await emailsTo(/./)).toEqual([]) + }) + + it('discards a created agreement that replaces nothing it holds', async () => { + const stored = uniqueAgreement() + const { licenseArn } = await insertLicense({ agreementId: stored }) + const deleted = deliverOnce([ + agreementCreated(uniqueAgreement()), + agreementCreated(uniqueAgreement(), uniqueAgreement()), + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 0, discarded: 2 }) + expect(deleted.sort()).toEqual(['r-0', 'r-1']) + expect(await emailsTo(/./)).toEqual([]) + expect((await readLicense(licenseArn))?.renewedAgreementId).toBeNull() + }) + + it('tells the team of an advisory on an agreement it holds, and ignores others', async () => { + const agreementId = uniqueAgreement() + await insertLicense({ agreementId }) + const deleted = deliverOnce([ + { + 'detail-type': 'Purchase Agreement Advisory Issued - Manufacturer', + detail: { agreement: { id: agreementId }, advisory: 'payment' }, + }, + { + 'detail-type': 'Purchase Agreement Advisory Resolved - Manufacturer', + detail: { agreement: { id: uniqueAgreement() } }, + }, + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 1 }) + expect(deleted.sort()).toEqual(['r-0', 'r-1']) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ + subject: expect.stringMatching(/advisory issued/), + text: expect.stringMatching(/payment/), + }), + ]) + }) +}) + +describe('a replaced agreement', () => { + async function linkedPredecessor() { + const account = uniqueAccount() + const ownerId = await createUser() + const organizationId = await createOrganization([{ userId: ownerId, role: 'owner' }]) + const previousAgreementId = uniqueAgreement() + const old = await insertLicense({ + organizationId, + linkedByUserId: ownerId, + contactEmail: CONTACT.contactEmail, + companyName: CONTACT.companyName, + customerAwsAccountId: account, + agreementId: previousAgreementId, + }) + return { ...old, account, ownerId, organizationId, previousAgreementId } + } + + it('hands the owner on when the Created event arrives first, telling the team once', async () => { + const old = await linkedPredecessor() + const agreementId = uniqueAgreement() + const newArn = uniqueArn() + entitlementsByLicense.set(newArn, [currentEntitlement({ CustomerAWSAccountId: old.account })]) + deliverOnce([ + agreementCreated(agreementId, old.previousAgreementId), + agreementCreated(agreementId, old.previousAgreementId), + licenseUpdated(newArn, agreementId), + ]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ retained: 0 }) + + expect(await readLicense(old.licenseArn)).toMatchObject({ renewedAgreementId: agreementId }) + expect(await readLicense(newArn)).toMatchObject({ + organizationId: old.organizationId, + linkedByUserId: old.ownerId, + contactEmail: CONTACT.contactEmail, + agreementId, + }) + const notices = await emailsTo(TEAM) + expect(notices.filter((email) => /Agreement replaced/.test(email.subject))).toHaveLength(1) + }) + + it('hands the owner on when the license event arrives first', async () => { + const old = await linkedPredecessor() + const agreementId = uniqueAgreement() + const newArn = uniqueArn() + entitlementsByLicense.set(newArn, [currentEntitlement({ CustomerAWSAccountId: old.account })]) + deliverOnce([licenseUpdated(newArn, agreementId)]) + await reconcileAwsMarketplaceLicenses() + expect((await readLicense(newArn))?.organizationId).toBeNull() + + deliverOnce([agreementCreated(agreementId, old.previousAgreementId)]) + await reconcileAwsMarketplaceLicenses() + + expect(await readLicense(newArn)).toMatchObject({ + organizationId: old.organizationId, + linkedByUserId: old.ownerId, + }) + const notices = await emailsTo(TEAM) + expect(notices.filter((email) => /Agreement replaced/.test(email.subject))).toHaveLength(1) + }) + + it('still tells the team when a license event already moved the row to the new agreement', async () => { + const old = await linkedPredecessor() + const agreementId = uniqueAgreement() + entitlementsByLicense.set(old.licenseArn, [ + currentEntitlement({ CustomerAWSAccountId: old.account }), + ]) + deliverOnce([ + licenseUpdated(old.licenseArn, agreementId, new Date()), + agreementCreated(agreementId, old.previousAgreementId), + ]) + + await reconcileAwsMarketplaceLicenses() + + expect((await readLicense(old.licenseArn))?.agreementId).toBe(agreementId) + const notices = await emailsTo(TEAM) + expect(notices.filter((email) => /Agreement replaced/.test(email.subject))).toHaveLength(1) + }) + + it('does not let a late A→B overwrite B→C', async () => { + const old = await linkedPredecessor() + const middle = uniqueAgreement() + const latest = uniqueAgreement() + deliverOnce([ + agreementCreated(middle, old.previousAgreementId), + agreementCreated(latest, middle), + agreementCreated(middle, old.previousAgreementId), + ]) + + await reconcileAwsMarketplaceLicenses() + + expect((await readLicense(old.licenseArn))?.renewedAgreementId).toBe(latest) + }) + + it('does not let a redelivered old license event roll the agreement back', async () => { + const newer = uniqueAgreement() + const older = uniqueAgreement() + const { licenseArn } = await insertLicense() + entitlementsByLicense.set(licenseArn, [currentEntitlement()]) + deliverOnce([ + licenseUpdated(licenseArn, newer, new Date(Date.now() - 1000)), + licenseUpdated(licenseArn, older, new Date(Date.now() - 60_000)), + ]) + + await reconcileAwsMarketplaceLicenses() + + expect((await readLicense(licenseArn))?.agreementId).toBe(newer) + }) + + it('tells the team not to cancel when a superseded license ends', async () => { + const old = await linkedPredecessor() + await db + .update(awsMarketplaceLicense) + .set({ + renewedAgreementId: uniqueAgreement(), + lastVerificationAttemptAt: new Date(Date.now() - 2 * DAY_MS), + }) + .where(eq(awsMarketplaceLicense.id, old.id)) + entitlementsByLicense.set(old.licenseArn, []) + deliverOnce([]) + + await reconcileAwsMarketplaceLicenses() + + const [notice] = await emailsTo(TEAM) + expect(notice.text).toMatch(/Do not cancel/) + expect(notice.text).not.toMatch(CANCEL_CALL) + }) +}) + +describe('the admin license route', () => { + function adminRequest( + body?: Record, + key: string | null = 'admin-key-placeholder' + ) { + return new NextRequest( + 'http://localhost:3000/api/v1/admin/dashboard/aws-marketplace-licenses?limit=250&offset=0', + { + method: body ? 'POST' : 'GET', + headers: { + 'content-type': 'application/json', + ...(key && { 'x-admin-key': key }), + }, + ...(body && { body: JSON.stringify(body) }), + } + ) + } + + it('refuses a caller without the admin key', async () => { + const { id, licenseArn } = await insertLicense() + const organizationId = await createOrganization([{ userId: await createUser(), role: 'owner' }]) + + const response = await adminLink(adminRequest({ licenseId: id, organizationId }, null), {}) + + expect(response.status).toBe(401) + expect((await readLicense(licenseArn))?.organizationId).toBeNull() + }) + + it('lists a license while unlinked, links it, and refuses to move it', async () => { + const { id, licenseArn } = await insertLicense() + const organizationId = await createOrganization([{ userId: await createUser(), role: 'owner' }]) + const otherOrganizationId = await createOrganization([ + { userId: await createUser(), role: 'owner' }, + ]) + const listed = await (await adminList(adminRequest(), {})).json() + expect(listed).toMatchObject({ + data: expect.arrayContaining([expect.objectContaining({ id })]), + }) + + const linked = await adminLink(adminRequest({ licenseId: id, organizationId }), {}) + const moved = await adminLink( + adminRequest({ licenseId: id, organizationId: otherOrganizationId }), + {} + ) + + expect(linked.status).toBe(200) + expect(moved.status).toBe(409) + expect((await readLicense(licenseArn))?.organizationId).toBe(organizationId) + await vi.waitFor(async () => { + const entries = await db + .select({ action: auditLog.action }) + .from(auditLog) + .where(eq(auditLog.resourceId, id)) + expect(entries).toEqual([{ action: 'aws_marketplace_license.linked' }]) + }) + await db.delete(auditLog).where(eq(auditLog.resourceId, id)) + }) +}) diff --git a/apps/sim/lib/billing/aws-marketplace/notice-events.ts b/apps/sim/lib/billing/aws-marketplace/notice-events.ts new file mode 100644 index 00000000000..789d7461a31 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/notice-events.ts @@ -0,0 +1,34 @@ +export const AWS_MARKETPLACE_TEAM_NOTICE_EVENT = 'aws-marketplace.team-notice' +export const AWS_MARKETPLACE_BUYER_NOTICE_EVENT = 'aws-marketplace.buyer-notice' + +export interface AwsMarketplaceNoticeTerms { + status: 'pending' | 'active' | 'inactive' + dimension: string | null + quantity: number | null + expiresAt: string | null +} + +/** What the team is told; a retry renders it again from the license's current row. */ +export type AwsMarketplaceTeamNotice = + | { kind: 'new-license'; licenseId: string; source: 'registration' | 'event' } + | { kind: 'taken'; licenseId: string; userId: string } + | { kind: 'contact'; licenseId: string } + | { kind: 'license-event-received'; licenseId: string } + | { + kind: 'terms-changed' + licenseId: string + before: AwsMarketplaceNoticeTerms + after: AwsMarketplaceNoticeTerms + } + | { kind: 'renewed'; licenseIds: string[]; previousAgreementId: string; agreementId: string } + | { + kind: 'advisory' + licenseIds: string[] + agreementId: string + state: 'issued' | 'resolved' + detail: string + } + +export interface AwsMarketplaceBuyerNotice { + licenseId: string +} diff --git a/apps/sim/lib/billing/aws-marketplace/notifications.ts b/apps/sim/lib/billing/aws-marketplace/notifications.ts new file mode 100644 index 00000000000..81957e7dd3e --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/notifications.ts @@ -0,0 +1,262 @@ +import { createLogger } from '@sim/logger' +import { + AWS_MARKETPLACE_BUYER_NOTICE_EVENT, + AWS_MARKETPLACE_TEAM_NOTICE_EVENT, + type AwsMarketplaceBuyerNotice, + type AwsMarketplaceNoticeTerms, + type AwsMarketplaceTeamNotice, +} from '@/lib/billing/aws-marketplace/notice-events' +import { AWS_MARKETPLACE_SETUP_TURNAROUND } from '@/lib/billing/aws-marketplace/onboarding' +import { + type AwsMarketplaceLicenseRecord, + getLicensesByIds, + getLinkParties, + listOrganizationsLinkedToAccount, +} from '@/lib/billing/aws-marketplace/repository' +import { env } from '@/lib/core/config/env' +import type { OutboxHandler, OutboxHandlerRegistry } from '@/lib/core/outbox/service' +import { getEmailDomain } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { getBrandConfig } from '@/ee/whitelabeling' + +const logger = createLogger('AwsMarketplaceNotifications') + +/** + * Enterprise provisioning otherwise sends Stripe invoices, and AWS requires a listed product to be + * billed entirely through its dimensions, so paused collection is the one safe way to provision. + */ +const PROVISION = + 'Provision through the admin API (POST /api/v1/admin/dashboard/enterprise-provisioning, or enterprise-owner-claims for a new owner) with pausePaymentCollection: true, sized from the dimension, quantity, and expiry above. Never send a Stripe invoice for a Marketplace customer: AWS bills them.' +const LINK = + 'Then link the license with POST /api/v1/admin/dashboard/aws-marketplace-licenses (licenseId, organizationId).' +const CANCEL = + 'Cancel the Enterprise subscription with POST /api/v1/admin/dashboard/organizations//cancellation (operationId: a new UUID, timing: immediate), unless a replacement notice for this account follows. The organization goes dormant and is not deleted.' +const NO_ORGANIZATION_TO_CANCEL = + 'No organization holds this license. If you provisioned one for this buyer, cancel it there.' +const UNCONFIRMED = + 'Unconfirmed: AWS has returned no entitlement for this license yet. Do not provision it until its status is active.' + +interface RenderedEmail { + to: string + subject: string + text: string + replyTo?: string | null +} + +function teamInbox(): string { + return `enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}` +} + +function formatTerms(terms: AwsMarketplaceNoticeTerms): string { + return `${terms.status}, dimension ${terms.dimension ?? 'not provided'}, quantity ${ + terms.quantity ?? 'not provided' + }, expires ${terms.expiresAt ?? 'never'}` +} + +async function describeLicense(license: AwsMarketplaceLicenseRecord): Promise { + const { organizationName } = await getLinkParties(license.organizationId, null) + return `Organization: ${ + license.organizationId ? `${organizationName ?? 'Unknown'} (${license.organizationId})` : 'None' + } +License ID: ${license.id} +License: ${license.licenseArn} +AWS account: ${license.customerAwsAccountId} +Agreement: ${license.agreementId ?? 'Not provided'} +Status: ${license.status} +Dimension: ${license.dimension ?? 'Not provided'} +Quantity: ${license.quantity ?? 'Not provided'} +Expires: ${license.expiresAt?.toISOString() ?? 'Never'} +Contact email: ${license.contactEmail ?? 'Not provided'} +Company: ${license.companyName ?? 'Not provided'} +License Updated event: ${ + license.licenseEventReceivedAt + ? `received ${license.licenseEventReceivedAt.toISOString()}` + : 'not received yet; AWS says not to activate a subscription until it arrives' + }` +} + +/** + * What is left for the team to do about one license AWS has just changed. It reads the license as + * it stands now, because an outbox retry can deliver a notice after a newer change. + */ +function nextStepForTerms(license: AwsMarketplaceLicenseRecord): string { + if (license.status === 'inactive' && license.renewedAgreementId) { + return `Agreement ${license.renewedAgreementId} replaced this license's agreement. Do not cancel on this notice: AWS grants the replacement its own license, so check that one.` + } + if (license.status === 'inactive') { + return license.organizationId ? CANCEL : NO_ORGANIZATION_TO_CANCEL + } + if (license.status === 'active' && !license.organizationId) return `${PROVISION} ${LINK}` + return 'Update the Enterprise provisioning to match. Never send a Stripe invoice for a Marketplace customer: AWS bills them.' +} + +/** Each notice's subject, opening, and next step; the license details between them are shared. */ +async function teamNoticeCopy( + notice: AwsMarketplaceTeamNotice, + license: AwsMarketplaceLicenseRecord +): Promise<{ subject: string; opening: string; next: string; replyTo?: string | null }> { + const account = `AWS account ${license.customerAwsAccountId}` + switch (notice.kind) { + case 'new-license': { + const linked = await listOrganizationsLinkedToAccount({ + customerAwsAccountId: license.customerAwsAccountId, + productCode: license.productCode, + excludingLicenseArn: license.licenseArn, + }) + const others = linked.length + ? linked.map((org) => `${org.name} (${org.id})`).join(', ') + : 'None' + return { + subject: `New license for ${account}`, + opening: `${ + notice.source === 'registration' + ? 'A buyer started AWS Marketplace setup. They may not finish it.' + : 'An AWS Marketplace license appeared that no buyer has registered.' + }${license.status === 'pending' ? `\n${UNCONFIRMED}` : ''} +Organizations linked to other licenses of this account: ${others}`, + next: license.organizationId + ? 'It carries on the organization of the license it replaces. Keep that organization provisioned to the terms above.' + : license.status === 'active' + ? `${PROVISION} ${LINK}` + : 'Do not provision this license. A notice follows if AWS reports it active.', + } + } + case 'taken': { + const { userName, userEmail } = await getLinkParties(null, notice.userId) + return { + subject: license.organizationId + ? `Linked to an organization: ${account}` + : `${license.contactEmail ?? userEmail ?? notice.userId} (no organization)`, + opening: `AWS Marketplace license ${ + license.organizationId ? 'linked' : 'registered to an account, no organization yet' + }. Taken by ${userName ?? 'Unknown'} <${userEmail ?? 'unknown'}> (${notice.userId}). The buyer was told to wait for you to contact them.`, + next: license.organizationId + ? PROVISION + : `Create the Enterprise organization for this buyer. ${PROVISION} ${LINK}`, + replyTo: license.contactEmail ?? userEmail, + } + } + case 'contact': { + const confirming = license.status === 'pending' + return { + subject: `Buyer contact: ${account}`, + opening: confirming + ? `A buyer left contact details while AWS confirms their subscription.\n${UNCONFIRMED}` + : 'A buyer changed their contact details.', + next: confirming + ? 'Nothing to provision on this notice; a separate one follows when AWS confirms it.' + : 'Nothing to provision on this notice. Use these contact details from now on.', + replyTo: license.contactEmail, + } + } + case 'license-event-received': + return { + subject: `License Updated event received: ${account}`, + opening: + 'AWS sent the License Updated event for a license already taken. AWS says not to activate a subscription until it arrives.', + next: + license.status === 'active' + ? 'Provisioning that was waiting for this event can go ahead, following the earlier notice.' + : 'Do not provision it until its status is active.', + } + case 'terms-changed': + return { + subject: `License ${notice.before.status} → ${notice.after.status}: ${account}`, + opening: `AWS Marketplace changed what this license grants. +Before: ${formatTerms(notice.before)} +After: ${formatTerms(notice.after)}`, + next: nextStepForTerms(license), + } + case 'renewed': + return { + subject: `Agreement replaced: ${account}`, + opening: `An AWS Marketplace agreement was replaced. +Previous agreement: ${notice.previousAgreementId} +New agreement: ${notice.agreementId}`, + next: 'AWS grants a new license with every new agreement. It inherits the organization below when it arrives, and a notice follows. Do not cancel because the previous license ends.', + } + case 'advisory': + return { + subject: `Agreement advisory ${notice.state}: ${account}`, + opening: `AWS Marketplace ${notice.state} an advisory on agreement ${notice.agreementId}. +Event detail: ${notice.detail}`, + next: 'Review the agreement in the AWS Marketplace Management Portal.', + } + } +} + +async function renderTeamNotice(notice: AwsMarketplaceTeamNotice): Promise { + const licenses = await getLicensesByIds( + 'licenseIds' in notice ? notice.licenseIds : [notice.licenseId] + ) + const license = licenses[0] + if (!license) return null + const copy = await teamNoticeCopy(notice, license) + const described = await Promise.all(licenses.map(describeLicense)) + return { + to: teamInbox(), + subject: `[AWS MARKETPLACE] ${copy.subject}`, + replyTo: copy.replyTo, + text: `${copy.opening} + +${described.join('\n\n')} + +${copy.next} +`, + } +} + +/** AWS's manual-setup guidance, in its own order: being set up, wait, turnaround, contact. */ +async function renderBuyerNotice(notice: AwsMarketplaceBuyerNotice): Promise { + const [license] = await getLicensesByIds([notice.licenseId]) + if (!license?.contactEmail) return null + const brand = getBrandConfig() + return { + to: license.contactEmail, + subject: `Your ${brand.name} account is being set up`, + replyTo: brand.supportEmail, + text: `Thanks for subscribing to ${brand.name} through AWS Marketplace. + +Your account is being set up. Please wait for us to contact you. We expect to finish ${AWS_MARKETPLACE_SETUP_TURNAROUND}. + +Questions? Contact ${brand.supportEmail}. +`, + } +} + +/** + * Sends one rendered notice. A failure throws, so the outbox retries it with backoff. Delivery is + * at least once: the checkpoint narrows the window for a repeat but a crash right after the send + * can still repeat it, which a person reading these emails can absorb. + */ +function noticeHandler( + render: (notice: T) => Promise +): OutboxHandler { + return async (rawPayload, context) => { + const payload = rawPayload as T & { sentAt?: string } + if (payload.sentAt) return + const email = await render(payload) + if (!email) { + logger.warn('Skipping an AWS Marketplace notice with nothing left to say', { + eventId: context.eventId, + }) + return + } + const result = await sendEmail({ + to: [email.to], + subject: email.subject, + text: email.text, + from: getFromEmailAddress(), + ...(email.replyTo && { replyTo: email.replyTo }), + emailType: 'transactional', + }) + if (!result.success) throw new Error(result.message || 'Failed to send the notice') + await context.checkpointPayload({ sentAt: new Date().toISOString() }) + } +} + +export const awsMarketplaceOutboxHandlers = { + [AWS_MARKETPLACE_TEAM_NOTICE_EVENT]: noticeHandler(renderTeamNotice), + [AWS_MARKETPLACE_BUYER_NOTICE_EVENT]: noticeHandler(renderBuyerNotice), +} as const satisfies OutboxHandlerRegistry diff --git a/apps/sim/lib/billing/aws-marketplace/onboarding.ts b/apps/sim/lib/billing/aws-marketplace/onboarding.ts new file mode 100644 index 00000000000..bf463b1820d --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/onboarding.ts @@ -0,0 +1,14 @@ +/** The onboarding page the registration redirect lands on. Client-safe. */ +export const AWS_MARKETPLACE_ONBOARDING_PATH = '/marketplace/aws' + +/** Why a registration ended without a claim, carried to the onboarding page as `?error=`. */ +export const AWS_MARKETPLACE_ONBOARDING_ERRORS = [ + 'invalid', + 'no-subscription', + 'not-started', + 'unavailable', +] as const +export type AwsMarketplaceOnboardingError = (typeof AWS_MARKETPLACE_ONBOARDING_ERRORS)[number] + +/** What the buyer is promised on the onboarding page and in their confirmation email. */ +export const AWS_MARKETPLACE_SETUP_TURNAROUND = 'within 2 business days' diff --git a/apps/sim/lib/billing/aws-marketplace/reconcile.ts b/apps/sim/lib/billing/aws-marketplace/reconcile.ts new file mode 100644 index 00000000000..5c9a8dc6fea --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/reconcile.ts @@ -0,0 +1,463 @@ +import { + ChangeMessageVisibilityCommand, + DeleteMessageCommand, + type Message, + ReceiveMessageCommand, +} from '@aws-sdk/client-sqs' +import { createLogger } from '@sim/logger' +import { toStringOrNull } from '@sim/utils/coerce' +import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { getEventsQueueClient } from '@/lib/billing/aws-marketplace/client' +import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { + isInvalidEntitlementRequest, + verifyLicense, +} from '@/lib/billing/aws-marketplace/entitlements' +import { + applyAgreementRenewal, + enqueueAgreementAdvisory, + listLicensesDueForReverify, + listLicensesForAgreement, + markVerificationAttempted, + recordLicenseVerification, +} from '@/lib/billing/aws-marketplace/repository' +import { env } from '@/lib/core/config/env' + +const logger = createLogger('AwsMarketplaceReconcile') + +const EVENT_SOURCE = 'aws.agreement-marketplace' +const LICENSE_UPDATED_EVENT = 'License Updated - Manufacturer' +const LICENSE_DEPROVISIONED_EVENT = 'License Deprovisioned - Manufacturer' +const AGREEMENT_ENDED_EVENT_TYPES = new Set([ + 'Purchase Agreement Ended - Proposer', + 'Purchase Agreement Ended - Manufacturer', +]) +const AGREEMENT_CREATED_EVENT_TYPES = new Set([ + 'Purchase Agreement Created - Proposer', + 'Purchase Agreement Created - Manufacturer', +]) +const ADVISORY_STATE_BY_EVENT_TYPE = new Map([ + ['Purchase Agreement Advisory Issued - Manufacturer', 'issued'], + ['Purchase Agreement Advisory Resolved - Manufacturer', 'resolved'], +]) +const MAX_ADVISORY_DETAIL_LENGTH = 2_000 + +/** SQS caps one receive at 10 messages; a run drains at most this many batches. */ +const RECEIVE_BATCH_SIZE = 10 +const MAX_RECEIVE_BATCHES = 10 +const RECEIVE_WAIT_SECONDS = 2 +/** One license's read is aborted here, whatever the SDK's three attempts have reached. */ +const LICENSE_READ_BUDGET_MS = 15_000 +/** + * A batch of 10 single-license messages reads for at most 10 × 15 s = 150 s, and notices are only + * enqueued, so each receive hides its batch for 180 s; a multi-license message extends its own + * visibility per license, and if that lets a sibling reappear, handling it again is idempotent. A + * run starts no batch or re-verify that could read past 6 minutes, inside the cron's 10-minute lock. + */ +const BATCH_BUDGET_MS = RECEIVE_BATCH_SIZE * LICENSE_READ_BUDGET_MS +const VISIBILITY_TIMEOUT_SECONDS = 180 +const RUN_BUDGET_MS = 6 * 60 * 1000 + +const REVERIFY_AFTER_MS = 24 * 60 * 60 * 1000 +const REVERIFY_BATCH_SIZE = 50 + +interface AwsMarketplaceReconcileSummary { + received: number + applied: number + discarded: number + /** A Deprovisioned event for a license that has no row, so nothing is left to record. */ + skipped: number + retained: number + reverified: number + reverifyFailed: number + /** The run stopped draining or re-verifying because its wall-clock budget ran out. */ + outOfBudget: boolean +} + +/** Whether work that may take up to `workMs` still ends inside the run's budget. */ +type BudgetCheck = (workMs: number) => boolean + +interface DrainContext { + productCode: string + queueUrl: string + summary: AwsMarketplaceReconcileSummary + fits: BudgetCheck +} + +type MarketplaceEvent = + | { + kind: 'license' + deprovisioned: boolean + licenseArn: string | null + productCode: string | null + agreementId: string | null + acceptorAccountId: string | null + occurredAt: Date | null + } + | { kind: 'agreement-ended'; agreementId: string } + | { kind: 'agreement-created'; agreementId: string; previousAgreementId: string | null } + | { kind: 'advisory'; agreementId: string; state: 'issued' | 'resolved'; detail: string } + +function parseEventTime(value: unknown): Date | null { + const text = toStringOrNull(value) + const time = text ? new Date(text) : null + return time && Number.isFinite(time.getTime()) ? time : null +} + +/** + * The event a queue message carries, or null for a body that is not one. Agreement events name no + * license, and the Proposer Ended event names no product, so they are matched by agreement ID. + */ +function parseMarketplaceEvent(body: string | undefined): MarketplaceEvent | null { + if (!body) return null + let event: Record + try { + event = toRecord(JSON.parse(body)) + } catch { + return null + } + const detailType = toStringOrNull(event['detail-type']) + if (event.source !== EVENT_SOURCE || !detailType) return null + const detail = toRecord(event.detail) + const agreement = toRecord(detail.agreement) + const agreementId = toStringOrNull(agreement.id) + if (detailType === LICENSE_UPDATED_EVENT || detailType === LICENSE_DEPROVISIONED_EVENT) { + return { + kind: 'license', + deprovisioned: detailType === LICENSE_DEPROVISIONED_EVENT, + licenseArn: toStringOrNull(toRecord(detail.license).arn), + productCode: toStringOrNull(toRecord(detail.product).code), + agreementId, + acceptorAccountId: toStringOrNull(toRecord(detail.acceptor).accountId), + occurredAt: parseEventTime(event.time), + } + } + if (!agreementId) return null + if (AGREEMENT_ENDED_EVENT_TYPES.has(detailType)) return { kind: 'agreement-ended', agreementId } + if (AGREEMENT_CREATED_EVENT_TYPES.has(detailType)) { + return { + kind: 'agreement-created', + agreementId, + previousAgreementId: toStringOrNull(agreement.previousAgreementId), + } + } + const advisoryState = ADVISORY_STATE_BY_EVENT_TYPE.get(detailType) + if (advisoryState) { + return { + kind: 'advisory', + agreementId, + state: advisoryState, + detail: truncate(JSON.stringify(detail), MAX_ADVISORY_DETAIL_LENGTH), + } + } + return null +} + +type MessageOutcome = 'applied' | 'discarded' | 'skipped' | 'retained' + +/** Stamps the attempt, asks the Entitlement Service, and records its answer. */ +async function reverifyLicense( + productCode: string, + licenseArn: string, + options: { + ended?: boolean + agreement?: { agreementId: string | null; seenAt: Date | null } + fallbackAccountId?: string | null + licenseEventReceivedAt?: Date + } = {} +) { + const verifiedAt = new Date() + await markVerificationAttempted(licenseArn, verifiedAt) + const summary = await verifyLicense( + productCode, + licenseArn, + AbortSignal.timeout(LICENSE_READ_BUDGET_MS) + ) + const outcome = await recordLicenseVerification({ + licenseArn, + productCode, + verification: { summary, verifiedAt, ended: options.ended }, + agreement: options.agreement, + fallbackAccountId: options.fallbackAccountId, + licenseEventReceivedAt: options.licenseEventReceivedAt, + }) + return { summary, outcome } +} + +async function extendVisibility( + context: DrainContext, + message: Message, + messageId: string | undefined +) { + if (!message.ReceiptHandle) return + try { + await getEventsQueueClient().send( + new ChangeMessageVisibilityCommand({ + QueueUrl: context.queueUrl, + ReceiptHandle: message.ReceiptHandle, + VisibilityTimeout: VISIBILITY_TIMEOUT_SECONDS, + }) + ) + } catch (error) { + logger.warn('Could not extend an AWS Marketplace event; it may be redelivered', { + messageId, + message: getErrorMessage(error, 'Unknown error'), + }) + } +} + +/** + * Each license gets its own read bound and commits on its own, so one that fails or runs out of + * time costs no other's progress. The message stays queued until AWS reports all of them ended, + * since a read can lag the event, and a redelivery starts with those least recently attempted. An + * agreement ID with no license is normal: the seller account has other products. + */ +async function applyAgreementEnded( + context: DrainContext, + agreementId: string, + message: Message +): Promise { + const messageId = message.MessageId + const licenseArns = await listLicensesForAgreement(context.productCode, agreementId) + if (licenseArns.length === 0) { + logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId }) + return 'discarded' + } + let incomplete = false + for (const [index, licenseArn] of licenseArns.entries()) { + if (!context.fits(LICENSE_READ_BUDGET_MS)) { + context.summary.outOfBudget = true + return 'retained' + } + if (index > 0) await extendVisibility(context, message, messageId) + try { + const { summary } = await reverifyLicense(context.productCode, licenseArn, { ended: true }) + if (summary.active) { + incomplete = true + logger.info('AWS still reports a license of an ended agreement current', { messageId }) + } + } catch (error) { + if (isInvalidEntitlementRequest(error)) { + logger.warn('The Entitlement Service rejected a license of an ended agreement', { + messageId, + message: getErrorMessage(error, 'Unknown error'), + }) + continue + } + incomplete = true + logger.warn('Could not re-verify a license of an ended agreement', { + messageId, + message: getErrorMessage(error, 'Unknown error'), + }) + } + } + return incomplete ? 'retained' : 'applied' +} + +async function applyLicenseEvent( + context: DrainContext, + event: Extract, + messageId: string | undefined +): Promise { + const { productCode } = context + if (event.productCode !== productCode) { + logger.warn('Discarding an AWS Marketplace event for another product', { messageId }) + return 'discarded' + } + if (!event.licenseArn) { + logger.warn('Leaving an AWS Marketplace license event with no ARN for the dead-letter queue', { + messageId, + }) + return 'retained' + } + const { summary, outcome } = await reverifyLicense(productCode, event.licenseArn, { + ended: event.deprovisioned, + agreement: { agreementId: event.agreementId, seenAt: event.occurredAt }, + fallbackAccountId: event.acceptorAccountId, + licenseEventReceivedAt: event.deprovisioned ? undefined : new Date(), + }) + if (outcome.kind === 'skipped') { + if (!event.deprovisioned) { + logger.warn('Leaving a License Updated event for a license AWS does not describe yet', { + messageId, + }) + return 'retained' + } + logger.warn('Skipping a License Deprovisioned event for a license with no row', { messageId }) + return 'skipped' + } + if (event.deprovisioned && summary.active) { + logger.warn('AWS deprovisioned a license its Entitlement Service still reports as current', { + messageId, + licenseId: outcome.license.id, + }) + return 'retained' + } + return 'applied' +} + +async function applyEvent( + context: DrainContext, + event: MarketplaceEvent, + message: Message +): Promise { + const { productCode } = context + const messageId = message.MessageId + if (event.kind === 'license') return applyLicenseEvent(context, event, messageId) + if (event.kind === 'agreement-ended') { + return applyAgreementEnded(context, event.agreementId, message) + } + const involved = + event.kind === 'advisory' + ? await enqueueAgreementAdvisory({ + productCode, + agreementId: event.agreementId, + state: event.state, + detail: event.detail, + messageId: messageId ?? event.agreementId, + }) + : event.previousAgreementId + ? await applyAgreementRenewal({ + productCode, + previousAgreementId: event.previousAgreementId, + agreementId: event.agreementId, + }) + : false + if (involved) return 'applied' + logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId }) + return 'discarded' +} + +/** + * Re-reads what an event concerns rather than trusting the event's own fields. A message that can + * never apply is discarded; one that failed for a reason that may pass is retained, so SQS + * redelivers it and the dead-letter queue catches one that never succeeds. + */ +async function handleMessage(context: DrainContext, message: Message): Promise { + const messageId = message.MessageId + const event = parseMarketplaceEvent(message.Body) + if (!event) { + logger.warn('Discarding an unrecognized AWS Marketplace event', { messageId }) + return 'discarded' + } + try { + return await applyEvent(context, event, message) + } catch (error) { + if (isInvalidEntitlementRequest(error)) { + logger.warn('Discarding an AWS Marketplace event the Entitlement Service rejected', { + messageId, + message: getErrorMessage(error, 'Unknown error'), + }) + return 'discarded' + } + logger.warn('Leaving an AWS Marketplace event for redelivery', { + messageId, + message: getErrorMessage(error, 'Unknown error'), + }) + return 'retained' + } +} + +async function drainEvents(context: DrainContext): Promise { + const { queueUrl, summary, fits } = context + const client = getEventsQueueClient() + for (let batch = 0; batch < MAX_RECEIVE_BATCHES; batch++) { + if (!fits(BATCH_BUDGET_MS)) { + summary.outOfBudget = true + return + } + let messages: Message[] + try { + const result = await client.send( + new ReceiveMessageCommand({ + QueueUrl: queueUrl, + MaxNumberOfMessages: RECEIVE_BATCH_SIZE, + VisibilityTimeout: VISIBILITY_TIMEOUT_SECONDS, + WaitTimeSeconds: RECEIVE_WAIT_SECONDS, + }) + ) + messages = result.Messages ?? [] + } catch (error) { + logger.error('Could not receive AWS Marketplace events', { + message: getErrorMessage(error, 'Unknown error'), + }) + return + } + if (messages.length === 0) return + + for (const message of messages) { + // An ended agreement can read many licenses; what is not started stays queued for the next run. + if (!fits(LICENSE_READ_BUDGET_MS)) { + summary.outOfBudget = true + return + } + summary.received++ + const outcome = await handleMessage(context, message) + summary[outcome]++ + if (outcome === 'retained' || !message.ReceiptHandle) continue + try { + await client.send( + new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: message.ReceiptHandle }) + ) + } catch (error) { + logger.warn('Could not delete a handled AWS Marketplace event; it will be redelivered', { + messageId: message.MessageId, + message: getErrorMessage(error, 'Unknown error'), + }) + } + } + } +} + +async function reverifyStaleLicenses( + productCode: string, + summary: AwsMarketplaceReconcileSummary, + fits: BudgetCheck +): Promise { + const cutoff = new Date(Date.now() - REVERIFY_AFTER_MS) + const licenseArns = await listLicensesDueForReverify(productCode, cutoff, REVERIFY_BATCH_SIZE) + for (const licenseArn of licenseArns) { + if (!fits(LICENSE_READ_BUDGET_MS)) { + summary.outOfBudget = true + return + } + try { + await reverifyLicense(productCode, licenseArn) + summary.reverified++ + } catch (error) { + summary.reverifyFailed++ + logger.warn('Could not re-verify an AWS Marketplace license', { + message: getErrorMessage(error, 'Unknown error'), + }) + } + } +} + +/** + * Applies queued agreement events, then re-reads licenses not attempted within a day. Both passes + * are bounded in count and wall-clock time, and idempotent: every write is the Entitlement + * Service's current answer, and an older read never overwrites a newer one. Returns null when the + * feature is off. + */ +export async function reconcileAwsMarketplaceLicenses(): Promise { + const productCode = getAwsMarketplaceProductCode() + if (!productCode) return null + const summary: AwsMarketplaceReconcileSummary = { + received: 0, + applied: 0, + discarded: 0, + skipped: 0, + retained: 0, + reverified: 0, + reverifyFailed: 0, + outOfBudget: false, + } + const startedAt = Date.now() + const fits: BudgetCheck = (workMs) => Date.now() - startedAt + workMs <= RUN_BUDGET_MS + const queueUrl = env.AWS_MARKETPLACE_EVENTS_QUEUE_URL + if (queueUrl) await drainEvents({ productCode, queueUrl, summary, fits }) + await reverifyStaleLicenses(productCode, summary, fits) + return summary +} diff --git a/apps/sim/lib/billing/aws-marketplace/registration.ts b/apps/sim/lib/billing/aws-marketplace/registration.ts new file mode 100644 index 00000000000..0bbce158a0b --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/registration.ts @@ -0,0 +1,111 @@ +import { + ResolveCustomerCommand, + type ResolveCustomerCommandOutput, +} from '@aws-sdk/client-marketplace-metering' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { AWS_MARKETPLACE_CLAIM_TTL_MS, issueClaimToken } from '@/lib/billing/aws-marketplace/claim' +import { getMeteringClient } from '@/lib/billing/aws-marketplace/client' +import { type EntitlementSummary, verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' +import type { AwsMarketplaceOnboardingError } from '@/lib/billing/aws-marketplace/onboarding' +import { saveRegisteredLicense } from '@/lib/billing/aws-marketplace/repository' + +const logger = createLogger('AwsMarketplaceRegistration') + +/** Token failures the buyer can fix only by starting again from AWS Marketplace. */ +const REJECTED_TOKEN_ERRORS = new Set(['InvalidTokenException', 'ExpiredTokenException']) + +/** The buyer's browser is waiting, so AWS gets only a moment to finish after a purchase. */ +const NO_ENTITLEMENT_RETRY_DELAYS_MS = [500, 1_000] + +type AwsMarketplaceRegistration = + | { kind: 'claimed'; claimToken: string } + | { kind: AwsMarketplaceOnboardingError } + +function errorName(error: unknown): string | undefined { + return error instanceof Error ? error.name : undefined +} + +/** AWS may not have finished right after a purchase, so an empty answer is asked again briefly. */ +async function readEntitlements( + productCode: string, + licenseArn: string +): Promise<{ summary: EntitlementSummary; verifiedAt: Date }> { + let verifiedAt = new Date() + let summary = await verifyLicense(productCode, licenseArn) + for (const delayMs of NO_ENTITLEMENT_RETRY_DELAYS_MS) { + if (summary.entitlementCount > 0) break + await sleep(delayMs) + verifiedAt = new Date() + summary = await verifyLicense(productCode, licenseArn) + } + return { summary, verifiedAt } +} + +/** + * Turns the registration token AWS posts to the fulfillment URL into a license row. Every + * registration reads the license's entitlements. A license AWS has not confirmed yet still gets a + * claim, so the buyer can leave contact details; only a lapsed one does not. The token itself is + * never logged. + */ +export async function registerAwsMarketplaceBuyer( + productCode: string, + registrationToken: string +): Promise { + let customer: ResolveCustomerCommandOutput + try { + customer = await getMeteringClient().send( + new ResolveCustomerCommand({ RegistrationToken: registrationToken }) + ) + } catch (error) { + const name = errorName(error) + if (name && REJECTED_TOKEN_ERRORS.has(name)) { + logger.warn('AWS Marketplace rejected a registration token', { reason: name }) + return { kind: 'invalid' } + } + logger.error('Could not resolve an AWS Marketplace customer', { reason: name }) + return { kind: 'unavailable' } + } + + if (customer.ProductCode !== productCode) { + logger.warn('AWS Marketplace registration is for another product') + return { kind: 'invalid' } + } + // New SaaS integrations get no CustomerIdentifier; the account ID and license ARN name the buyer. + if (!customer.CustomerAWSAccountId) { + logger.warn('AWS Marketplace registration resolved without an account') + return { kind: 'invalid' } + } + // A future-dated agreement gets its ARN on its start date; the License Updated event records it. + if (!customer.LicenseArn) { + logger.info('AWS Marketplace registration is for an agreement that has not started') + return { kind: 'not-started' } + } + + try { + const verification = await readEntitlements(productCode, customer.LicenseArn) + const claim = issueClaimToken() + const saved = await saveRegisteredLicense({ + licenseArn: customer.LicenseArn, + productCode, + customerAwsAccountId: customer.CustomerAWSAccountId, + agreementId: customer.Metadata?.AgreementId ?? null, + verification, + claim: { hash: claim.hash, expiresAt: new Date(Date.now() + AWS_MARKETPLACE_CLAIM_TTL_MS) }, + }) + logger.info('Registered an AWS Marketplace license', { + licenseId: saved.id, + status: saved.status, + }) + return saved.status === 'inactive' + ? { kind: 'no-subscription' } + : { kind: 'claimed', claimToken: claim.token } + } catch (error) { + logger.error('Could not record an AWS Marketplace registration', { + reason: errorName(error), + message: getErrorMessage(error, 'Unknown error'), + }) + return { kind: 'unavailable' } + } +} diff --git a/apps/sim/lib/billing/aws-marketplace/repository.ts b/apps/sim/lib/billing/aws-marketplace/repository.ts new file mode 100644 index 00000000000..0b3a8ff2ef4 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/repository.ts @@ -0,0 +1,738 @@ +import { db } from '@sim/db' +import { awsMarketplaceLicense, member, organization, outboxEvent, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm' +import type { EntitlementSummary } from '@/lib/billing/aws-marketplace/entitlements' +import { + AWS_MARKETPLACE_BUYER_NOTICE_EVENT, + AWS_MARKETPLACE_TEAM_NOTICE_EVENT, + type AwsMarketplaceBuyerNotice, + type AwsMarketplaceNoticeTerms, + type AwsMarketplaceTeamNotice, +} from '@/lib/billing/aws-marketplace/notice-events' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import type { DbTransaction } from '@/lib/db/types' + +export type AwsMarketplaceLicenseRecord = typeof awsMarketplaceLicense.$inferSelect +type AwsMarketplaceLicenseStatus = AwsMarketplaceLicenseRecord['status'] + +const MAX_LICENSES_PER_AGREEMENT = 20 + +/** One verification read, as a writer applies it under the license's row lock. */ +export interface LicenseVerification { + summary: EntitlementSummary + /** When the read started; a read older than the row's last one changes nothing. */ + verifiedAt: Date + /** + * AWS said this license ended (Deprovisioned or Agreement Ended), so no entitlements means it + * lapsed rather than that AWS has not finished setting it up. + */ + ended?: boolean +} + +/** Where an agreement ID came from, and when that source was true. */ +interface AgreementObservation { + agreementId: string | null + seenAt: Date | null +} + +function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice, id?: string) { + return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id }) +} + +function enqueueBuyerNotice(tx: DbTransaction, notice: AwsMarketplaceBuyerNotice) { + return enqueueOutboxEvent(tx, AWS_MARKETPLACE_BUYER_NOTICE_EVENT, notice) +} + +/** Whether a read that started at `verifiedAt` may overwrite what the row last recorded. */ +export function isNewerRead(license: AwsMarketplaceLicenseRecord, verifiedAt: Date): boolean { + return license.lastVerifiedAt.getTime() <= verifiedAt.getTime() +} + +/** + * No entitlements after AWS has said the license ended, or on a license that was already confirmed + * once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending. + */ +function statusFromRead( + prior: AwsMarketplaceLicenseStatus | null, + { summary, ended }: LicenseVerification +): AwsMarketplaceLicenseStatus { + if (summary.active) return 'active' + if (summary.entitlementCount > 0 || ended) return 'inactive' + return prior === null || prior === 'pending' ? 'pending' : 'inactive' +} + +/** + * The columns a read writes. With no entitlements the last-known dimension, quantity, and expiry + * stay as they were, and only the status can change. An inactive license has nothing to claim. + */ +function verificationFields( + prior: AwsMarketplaceLicenseStatus | null, + verification: LicenseVerification +) { + const status = statusFromRead(prior, verification) + const { summary, verifiedAt } = verification + return { + status, + lastVerifiedAt: verifiedAt, + lastVerificationAttemptAt: verifiedAt, + updatedAt: new Date(), + ...(summary.entitlementCount > 0 && { + dimension: summary.dimension, + quantity: summary.quantity, + expiresAt: summary.expiresAt, + }), + ...(status === 'inactive' && { claimTokenHash: null, claimExpiresAt: null }), + } +} + +function termsOf(license: AwsMarketplaceLicenseRecord): AwsMarketplaceNoticeTerms { + return { + status: license.status, + dimension: license.dimension, + quantity: license.quantity, + expiresAt: license.expiresAt?.toISOString() ?? null, + } +} + +function termsDiffer(before: AwsMarketplaceNoticeTerms, after: AwsMarketplaceNoticeTerms): boolean { + return ( + before.status !== after.status || + before.dimension !== after.dimension || + before.quantity !== after.quantity || + before.expiresAt !== after.expiresAt + ) +} + +/** + * The one place a read reaches a stored license, for every writer: under the caller's row lock it + * applies the read unless a newer one is stored, and when that moves the terms, enqueues the team's + * notice in the same transaction. `alongside` is written either way. + */ +async function applyVerification( + tx: DbTransaction, + locked: AwsMarketplaceLicenseRecord, + verification: LicenseVerification, + alongside: Partial = {} +): Promise { + const fresh = isNewerRead(locked, verification.verifiedAt) + const [license] = await tx + .update(awsMarketplaceLicense) + .set({ + ...(fresh ? verificationFields(locked.status, verification) : { updatedAt: new Date() }), + ...alongside, + }) + .where(eq(awsMarketplaceLicense.id, locked.id)) + .returning() + const before = termsOf(locked) + const after = termsOf(license) + if (fresh && termsDiffer(before, after)) { + await enqueueTeamNotice(tx, { kind: 'terms-changed', licenseId: license.id, before, after }) + } + return license +} + +/** The agreement columns an observation may write: never one older than what is stored. */ +function agreementFields(locked: AwsMarketplaceLicenseRecord | null, seen: AgreementObservation) { + if (!seen.agreementId) return {} + const stored = locked?.agreementSeenAt + const older = stored ? !seen.seenAt || seen.seenAt.getTime() < stored.getTime() : false + if (older || (locked?.agreementId && !seen.seenAt)) return {} + return { agreementId: seen.agreementId, agreementSeenAt: seen.seenAt } +} + +async function lockByArn(tx: DbTransaction, licenseArn: string) { + const [locked] = await tx + .select() + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.licenseArn, licenseArn)) + .for('update') + .limit(1) + return locked ?? null +} + +/** + * AWS grants a new license with every new agreement. When a license with no owner names an + * agreement a linked license was renewed to, it carries on that license's organization, linker, + * and contact. Runs from whichever arrives second, the Created event or the license itself. + */ +async function inheritFromRenewedLicense( + tx: DbTransaction, + license: AwsMarketplaceLicenseRecord +): Promise { + if (!license.agreementId || license.organizationId || license.linkedByUserId) return license + const [predecessor] = await tx + .select() + .from(awsMarketplaceLicense) + .where( + and( + eq(awsMarketplaceLicense.renewedAgreementId, license.agreementId), + eq(awsMarketplaceLicense.productCode, license.productCode), + eq(awsMarketplaceLicense.customerAwsAccountId, license.customerAwsAccountId), + ne(awsMarketplaceLicense.id, license.id), + or( + isNotNull(awsMarketplaceLicense.organizationId), + isNotNull(awsMarketplaceLicense.linkedByUserId) + ) + ) + ) + .orderBy(asc(awsMarketplaceLicense.organizationId)) + .limit(1) + if (!predecessor) return license + const [inherited] = await tx + .update(awsMarketplaceLicense) + .set({ + organizationId: predecessor.organizationId, + linkedByUserId: predecessor.linkedByUserId, + contactEmail: predecessor.contactEmail, + companyName: predecessor.companyName, + updatedAt: new Date(), + }) + .where(eq(awsMarketplaceLicense.id, license.id)) + .returning() + return inherited +} + +/** + * Records a registration: the license row keyed by its ARN, with `claim` unless the newest answer + * is inactive. A new row tells the team at once, so a buyer who pays and abandons setup is seen. + */ +export async function saveRegisteredLicense(input: { + licenseArn: string + productCode: string + customerAwsAccountId: string + agreementId: string | null + verification: LicenseVerification + claim: { hash: string; expiresAt: Date } +}): Promise<{ id: string; status: AwsMarketplaceLicenseStatus }> { + const { verification, claim } = input + const claimFields = { claimTokenHash: claim.hash, claimExpiresAt: claim.expiresAt } + const seen = { agreementId: input.agreementId, seenAt: verification.verifiedAt } + return db.transaction(async (tx) => { + const status = statusFromRead(null, verification) + const [created] = await tx + .insert(awsMarketplaceLicense) + .values({ + id: generateId(), + licenseArn: input.licenseArn, + productCode: input.productCode, + customerAwsAccountId: input.customerAwsAccountId, + ...agreementFields(null, seen), + ...(status === 'inactive' ? {} : claimFields), + ...verificationFields(null, verification), + }) + .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) + .returning() + if (created) { + const license = await inheritFromRenewedLicense(tx, created) + await enqueueTeamNotice(tx, { + kind: 'new-license', + licenseId: license.id, + source: 'registration', + }) + return { id: license.id, status: license.status } + } + + const locked = await lockByArn(tx, input.licenseArn) + if (!locked) throw new Error('AWS Marketplace license vanished during registration') + const effective = isNewerRead(locked, verification.verifiedAt) + ? statusFromRead(locked.status, verification) + : locked.status + const written = await applyVerification(tx, locked, verification, { + ...agreementFields(locked, seen), + ...(effective === 'inactive' ? {} : claimFields), + }) + const license = await inheritFromRenewedLicense(tx, written) + return { id: license.id, status: license.status } + }) +} + +export type LicenseVerificationOutcome = + | { kind: 'created' | 'updated'; license: AwsMarketplaceLicenseRecord } + /** No row, and AWS described nothing to create one from. */ + | { kind: 'skipped' } + +/** + * Applies a verification read. A license AWS describes with an entitlement but nobody registered is + * recorded (a renewal gets a new ARN), carrying on a renewed license's owner when there is one; one + * AWS no longer describes is only updated if it exists. The first License Updated event for a + * license already taken tells the team, whose earlier notice said to wait for it. + */ +export async function recordLicenseVerification(input: { + licenseArn: string + productCode: string + verification: LicenseVerification + /** The account from the event, for an entitlement that omits it. */ + fallbackAccountId?: string | null + agreement?: AgreementObservation + /** When a License Updated event was applied, which AWS asks to wait for before activating. */ + licenseEventReceivedAt?: Date +}): Promise { + const { verification, licenseEventReceivedAt } = input + const seen = input.agreement ?? { agreementId: null, seenAt: null } + const stamp = licenseEventReceivedAt ? { licenseEventReceivedAt } : {} + return db.transaction(async (tx) => { + const accountId = verification.summary.customerAwsAccountId ?? input.fallbackAccountId + if (accountId && verification.summary.entitlementCount > 0) { + const [created] = await tx + .insert(awsMarketplaceLicense) + .values({ + id: generateId(), + licenseArn: input.licenseArn, + productCode: input.productCode, + customerAwsAccountId: accountId, + ...agreementFields(null, seen), + ...stamp, + ...verificationFields(null, verification), + }) + .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn }) + .returning() + if (created) { + const license = await inheritFromRenewedLicense(tx, created) + await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source: 'event' }) + return { kind: 'created', license } + } + } + const locked = await lockByArn(tx, input.licenseArn) + if (!locked) return { kind: 'skipped' } + const written = await applyVerification(tx, locked, verification, { + ...agreementFields(locked, seen), + ...stamp, + }) + const license = await inheritFromRenewedLicense(tx, written) + if ( + licenseEventReceivedAt && + !locked.licenseEventReceivedAt && + (license.organizationId || license.linkedByUserId) + ) { + await enqueueTeamNotice( + tx, + { kind: 'license-event-received', licenseId: license.id }, + `aws-marketplace-license-event-${license.id}` + ) + } + return { kind: 'updated', license } + }) +} + +/** + * Records that `agreementId` replaced `previousAgreementId`, from either arrival order, and tells + * the team. A renewal is accepted only on a row with none recorded or one naming the previous + * agreement, so a late A→B cannot overwrite B→C. Rows already on the new agreement count too, so + * the notice survives a license event that got here first, and SQS redelivery cannot send it twice. + * Returns whether any stored license was involved. + */ +export async function applyAgreementRenewal(input: { + productCode: string + previousAgreementId: string + agreementId: string +}): Promise { + const { productCode, previousAgreementId, agreementId } = input + return db.transaction(async (tx) => { + const predecessors = await tx + .select() + .from(awsMarketplaceLicense) + .where( + and( + eq(awsMarketplaceLicense.productCode, productCode), + or( + eq(awsMarketplaceLicense.agreementId, previousAgreementId), + eq(awsMarketplaceLicense.renewedAgreementId, previousAgreementId) + ) + ) + ) + .for('update') + const accepted = predecessors.filter( + (row) => row.renewedAgreementId === null || row.renewedAgreementId === previousAgreementId + ) + if (accepted.length > 0) { + await tx + .update(awsMarketplaceLicense) + .set({ renewedAgreementId: agreementId, updatedAt: new Date() }) + .where( + inArray( + awsMarketplaceLicense.id, + accepted.map((row) => row.id) + ) + ) + } + const successors = await tx + .select() + .from(awsMarketplaceLicense) + .where( + and( + eq(awsMarketplaceLicense.productCode, productCode), + eq(awsMarketplaceLicense.agreementId, agreementId) + ) + ) + .for('update') + for (const successor of successors) await inheritFromRenewedLicense(tx, successor) + + const licenseIds = [...accepted, ...successors].map((row) => row.id) + if (licenseIds.length === 0) return false + const noticeId = `aws-marketplace-renewal-${agreementId}` + const [sent] = await tx + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(eq(outboxEvent.id, noticeId)) + .limit(1) + if (!sent) { + await enqueueTeamNotice( + tx, + { kind: 'renewed', licenseIds, previousAgreementId, agreementId }, + noticeId + ) + } + return true + }) +} + +/** Tells the team of an AWS advisory on an agreement we hold; redelivery cannot send it twice. */ +export async function enqueueAgreementAdvisory(input: { + productCode: string + agreementId: string + state: 'issued' | 'resolved' + detail: string + messageId: string +}): Promise { + return db.transaction(async (tx) => { + const rows = await tx + .select({ id: awsMarketplaceLicense.id }) + .from(awsMarketplaceLicense) + .where(belongsToAgreement(input.productCode, input.agreementId)) + .limit(MAX_LICENSES_PER_AGREEMENT) + if (rows.length === 0) return false + const noticeId = `aws-marketplace-advisory-${input.messageId}` + const [sent] = await tx + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(eq(outboxEvent.id, noticeId)) + .limit(1) + if (!sent) { + await enqueueTeamNotice( + tx, + { + kind: 'advisory', + licenseIds: rows.map((row) => row.id), + agreementId: input.agreementId, + state: input.state, + detail: input.detail, + }, + noticeId + ) + } + return true + }) +} + +function belongsToAgreement(productCode: string, agreementId: string) { + return and( + eq(awsMarketplaceLicense.productCode, productCode), + or( + eq(awsMarketplaceLicense.agreementId, agreementId), + eq(awsMarketplaceLicense.renewedAgreementId, agreementId) + ) + ) +} + +/** + * The licenses an agreement covers, least recently attempted first, so a redelivered message moves + * on to the ones an earlier delivery did not reach. + */ +export async function listLicensesForAgreement( + productCode: string, + agreementId: string +): Promise { + const rows = await db + .select({ licenseArn: awsMarketplaceLicense.licenseArn }) + .from(awsMarketplaceLicense) + .where(belongsToAgreement(productCode, agreementId)) + .orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt)) + .limit(MAX_LICENSES_PER_AGREEMENT) + return rows.map((row) => row.licenseArn) +} + +/** This product's licenses longest since an attempt, up to `limit`, none attempted after `cutoff`. */ +export async function listLicensesDueForReverify( + productCode: string, + cutoff: Date, + limit: number +): Promise { + const rows = await db + .select({ licenseArn: awsMarketplaceLicense.licenseArn }) + .from(awsMarketplaceLicense) + .where( + and( + eq(awsMarketplaceLicense.productCode, productCode), + lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff) + ) + ) + .orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt)) + .limit(limit) + return rows.map((row) => row.licenseArn) +} + +/** Stamps an attempt to verify a license, before its outcome is known. */ +export async function markVerificationAttempted(licenseArn: string, attemptedAt: Date) { + await db + .update(awsMarketplaceLicense) + .set({ lastVerificationAttemptAt: attemptedAt }) + .where(eq(awsMarketplaceLicense.licenseArn, licenseArn)) +} + +export async function findLicenseByClaimHash( + claimTokenHash: string +): Promise { + const [row] = await db + .select() + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.claimTokenHash, claimTokenHash)) + .limit(1) + return row ?? null +} + +export async function lockLicense( + tx: DbTransaction, + licenseId: string +): Promise { + const [row] = await tx + .select() + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.id, licenseId)) + .for('update') + .limit(1) + return row ?? null +} + +interface BuyerContact { + email: string + companyName: string | null +} + +function contactChanged(locked: AwsMarketplaceLicenseRecord, contact: BuyerContact): boolean { + return locked.contactEmail !== contact.email || locked.companyName !== contact.companyName +} + +/** + * Consumes the claim under the caller's row lock, taking the license when `link` is given: for an + * organization, or for an account alone when its organization is null. A take tells the team and + * confirms to the buyer in the same transaction. A license the same party already holds keeps its + * link and announces only new contact details. + */ +export async function consumeLicenseClaim( + tx: DbTransaction, + locked: AwsMarketplaceLicenseRecord, + input: { + link: { organizationId: string | null; userId: string } | null + contact: BuyerContact + verification: LicenseVerification + } +): Promise { + const license = await applyVerification(tx, locked, input.verification, { + contactEmail: input.contact.email, + companyName: input.contact.companyName, + ...(input.link && { + organizationId: input.link.organizationId, + linkedByUserId: input.link.userId, + }), + claimTokenHash: null, + claimExpiresAt: null, + }) + if (input.link) { + await enqueueTeamNotice(tx, { kind: 'taken', licenseId: license.id, userId: input.link.userId }) + await enqueueBuyerNotice(tx, { licenseId: license.id }) + } else if (contactChanged(locked, input.contact)) { + await enqueueTeamNotice(tx, { kind: 'contact', licenseId: license.id }) + } + return license +} + +/** + * Applies a pending buyer's fresh read and their contact details, keeping the claim open. New + * details tell the team whom to reach while AWS finishes confirming. + */ +export async function saveLicenseContact( + tx: DbTransaction, + locked: AwsMarketplaceLicenseRecord, + input: { + contact: BuyerContact + verification: LicenseVerification + } +): Promise { + const changed = contactChanged(locked, input.contact) + const license = await applyVerification(tx, locked, input.verification, { + contactEmail: input.contact.email, + companyName: input.contact.companyName, + }) + if (changed) await enqueueTeamNotice(tx, { kind: 'contact', licenseId: license.id }) + return license +} + +/** Organizations the user owns or administers, which are the ones a license may be linked to. */ +export async function listAdministeredOrganizations( + userId: string +): Promise> { + return db + .select({ id: organization.id, name: organization.name }) + .from(member) + .innerJoin(organization, eq(member.organizationId, organization.id)) + .where(and(eq(member.userId, userId), inArray(member.role, ['owner', 'admin']))) + .orderBy(asc(organization.name)) +} + +const STATUS_COLUMNS = { + id: awsMarketplaceLicense.id, + status: awsMarketplaceLicense.status, + dimension: awsMarketplaceLicense.dimension, + quantity: awsMarketplaceLicense.quantity, + expiresAt: awsMarketplaceLicense.expiresAt, + lastVerifiedAt: awsMarketplaceLicense.lastVerifiedAt, +} + +/** What an organization's members see of the Marketplace licenses linked to it. */ +export async function listOrganizationLicenses(organizationId: string) { + return db + .select(STATUS_COLUMNS) + .from(awsMarketplaceLicense) + .where(eq(awsMarketplaceLicense.organizationId, organizationId)) + .orderBy(asc(awsMarketplaceLicense.createdAt)) +} + +/** What a buyer sees of the licenses registered to their account, before an organization exists. */ +export async function listAccountLicenses(userId: string) { + return db + .select(STATUS_COLUMNS) + .from(awsMarketplaceLicense) + .where( + and( + eq(awsMarketplaceLicense.linkedByUserId, userId), + isNull(awsMarketplaceLicense.organizationId) + ) + ) + .orderBy(asc(awsMarketplaceLicense.createdAt)) +} + +/** + * Whether AWS Marketplace bills this billing reference: a license that is not inactive, linked to + * the organization, or registered to the user's account with no organization yet. + */ +export async function holdsMarketplaceLicense(referenceId: string): Promise { + const [row] = await db + .select({ id: awsMarketplaceLicense.id }) + .from(awsMarketplaceLicense) + .where( + and( + ne(awsMarketplaceLicense.status, 'inactive'), + or( + eq(awsMarketplaceLicense.organizationId, referenceId), + and( + isNull(awsMarketplaceLicense.organizationId), + eq(awsMarketplaceLicense.linkedByUserId, referenceId) + ) + ) + ) + ) + .limit(1) + return Boolean(row) +} + +/** + * Licenses with no organization, newest first, for the admin dashboard: those waiting for the team + * to link them, and inactive ones that never will be. + */ +export async function listUnlinkedLicenses(page: { limit: number; offset: number }) { + return db + .select() + .from(awsMarketplaceLicense) + .where(isNull(awsMarketplaceLicense.organizationId)) + .orderBy(desc(awsMarketplaceLicense.createdAt)) + .limit(page.limit) + .offset(page.offset) +} + +export async function countUnlinkedLicenses(): Promise { + const [row] = await db + .select({ total: count() }) + .from(awsMarketplaceLicense) + .where(isNull(awsMarketplaceLicense.organizationId)) + return row?.total ?? 0 +} + +/** Sets a license's organization under its row lock; any outstanding claim has no further use. */ +export async function linkLicenseForTeam( + tx: DbTransaction, + licenseId: string, + organizationId: string +): Promise { + const [license] = await tx + .update(awsMarketplaceLicense) + .set({ organizationId, claimTokenHash: null, claimExpiresAt: null, updatedAt: new Date() }) + .where(eq(awsMarketplaceLicense.id, licenseId)) + .returning() + return license +} + +export async function organizationExists(tx: DbTransaction, organizationId: string) { + const [row] = await tx + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + return Boolean(row) +} + +/** Who linked a license to which organization, for the team's provisioning notice. */ +export async function getLinkParties( + organizationId: string | null, + userId: string | null +): Promise<{ organizationName: string | null; userName: string | null; userEmail: string | null }> { + const [[org], [person]] = await Promise.all([ + organizationId + ? db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + : Promise.resolve([]), + userId + ? db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + : Promise.resolve([]), + ]) + return { + organizationName: org?.name ?? null, + userName: person?.name ?? null, + userEmail: person?.email ?? null, + } +} + +export async function getLicensesByIds(licenseIds: readonly string[]) { + if (licenseIds.length === 0) return [] + return db + .select() + .from(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.id, [...licenseIds])) +} + +/** Organizations already linked to another license of this AWS account, for the team to match. */ +export async function listOrganizationsLinkedToAccount(input: { + customerAwsAccountId: string + productCode: string + excludingLicenseArn: string +}): Promise> { + return db + .selectDistinct({ id: organization.id, name: organization.name }) + .from(awsMarketplaceLicense) + .innerJoin(organization, eq(awsMarketplaceLicense.organizationId, organization.id)) + .where( + and( + eq(awsMarketplaceLicense.customerAwsAccountId, input.customerAwsAccountId), + eq(awsMarketplaceLicense.productCode, input.productCode), + ne(awsMarketplaceLicense.licenseArn, input.excludingLicenseArn) + ) + ) + .orderBy(asc(organization.name)) +} diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index d80757b7a74..9cbe6b56cc0 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -512,7 +512,8 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ */ if (!isBillingEnabled && !isDataRetentionEnabled) { logger.info( - `[${jobType}] Skipping cleanup dispatch: billing is disabled and data retention is not enabled` + 'Skipping cleanup dispatch: billing is disabled and data retention is not enabled', + { jobType } ) return { jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 } } @@ -551,9 +552,9 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ }) await flushBatch() - logger.info( - `[${jobType}] Trigger cleanup chunks: ${succeeded} dispatched in ${jobIds.length} batch(es)` - ) + logger.info(`Trigger cleanup chunks: ${succeeded} dispatched in ${jobIds.length} batch(es)`, { + jobType, + }) return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount } } @@ -566,7 +567,8 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ succeeded++ } catch (error) { failed++ - logger.error(`[${jobType}] Inline cleanup chunk failed:`, { + logger.error('Inline cleanup chunk failed:', { + jobType, plan: payload.plan, label: payload.label, error, @@ -574,7 +576,7 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ } }) - logger.info(`[${jobType}] Inline cleanup chunks: ${succeeded} succeeded, ${failed} failed`) + logger.info(`Inline cleanup chunks: ${succeeded} succeeded, ${failed} failed`, { jobType }) return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount } } @@ -588,10 +590,10 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ succeeded++ } catch (reason) { failed++ - logger.error(`[${jobType}] Failed to enqueue chunk:`, { reason }) + logger.error('Failed to enqueue chunk:', { jobType, reason }) } }) - logger.info(`[${jobType}] Chunk enqueue: ${succeeded} succeeded, ${failed} failed`) + logger.info(`Chunk enqueue: ${succeeded} succeeded, ${failed} failed`, { jobType }) return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount } } diff --git a/apps/sim/lib/billing/core/chat-access.ts b/apps/sim/lib/billing/core/chat-access.ts index 72645f0afd8..7e86759e023 100644 --- a/apps/sim/lib/billing/core/chat-access.ts +++ b/apps/sim/lib/billing/core/chat-access.ts @@ -5,23 +5,30 @@ import { isFree } from '@/lib/billing/plan-helpers' import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' -const logger = createLogger('ChatAccess') +const logger = createLogger('PaidAccess') + +interface PaidAccessOptions { + /** + * The verdict for a free payer whose credit balance cannot be read. Chat admits, because a + * database blip must not lock a paying customer out and every turn is bounded by the usage + * check that follows. A hosted key denies, because nothing after acquisition bounds it. + */ + onBalanceUnavailable: 'admit' | 'deny' +} /** - * Whether a payer may use Chat (talking to Sim). Every turn spends hosted model credits, so a - * hosted deployment that bills withholds Chat from a payer on the free plan — unless that payer - * holds prepaid credits, which an admin grants to customers billed outside a subscription. + * Whether a payer may use a feature a hosted deployment reserves for paying payers: Chat, and + * hosted keys a tool marks `requiresPaidPlan`. A hosted deployment that bills withholds these from + * a payer on the free plan, unless that payer holds prepaid credits, which an admin grants to + * customers billed outside a subscription. * * The plan is the payer's entitled subscription (active or past due), so a canceled * plan reads as free; a blocked payer is refused by the usage check every caller runs next. - * - * This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled - * job dispatch, Copilot key admission, and the workspace host context the composer reads, so the - * composer never offers a send that admission will refuse. A balance that cannot be read admits - * the payer: a database blip must not lock a paying customer out, and a free payer stays bounded - * by its usage limit. */ -export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise { +export async function payerHasPaidAccess( + payer: UsageUpgradePayer, + options: PaidAccessOptions +): Promise { if (!isHosted || !isBillingEnabled) return true if (!isFree(payer.payerSubscription?.plan)) return true try { @@ -31,10 +38,23 @@ export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise 0 } catch (error) { - logger.warn('Credit balance unavailable; admitting the payer to Chat', { + logger.warn('Credit balance unavailable for a free payer', { billingEntity: payer.billingEntity, + verdict: options.onBalanceUnavailable, error: getErrorMessage(error), }) - return true + return options.onBalanceUnavailable === 'admit' } } + +/** + * Whether a payer may use Chat (talking to Sim): {@link payerHasPaidAccess}, since every turn + * spends hosted model credits, admitting a payer whose balance cannot be read. + * + * This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled + * job dispatch, Copilot key admission, and the workspace host context the composer reads, so the + * composer never offers a send that admission will refuse. + */ +export function payerHasChatAccess(payer: UsageUpgradePayer): Promise { + return payerHasPaidAccess(payer, { onBalanceUnavailable: 'admit' }) +} diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts index de08e09baf6..0ffaf32a016 100644 --- a/apps/sim/lib/block-metadata/names.generated.ts +++ b/apps/sim/lib/block-metadata/names.generated.ts @@ -90,8 +90,10 @@ export const BLOCK_NAMES: Readonly> = { fireflies_v2: 'Fireflies', fivetran: 'Fivetran', flint: 'Flint', + flow: 'Flow Engineering', function: 'Function', gamma: 'Gamma', + gauge: 'Gauge', generic_webhook: 'Webhook Trigger', github_v2: 'GitHub', gitlab: 'GitLab', @@ -140,6 +142,7 @@ export const BLOCK_NAMES: Readonly> = { instantly: 'Instantly', intercom_v2: 'Intercom', intune: 'Microsoft Intune', + jama: 'Jama Connect', jina: 'Jina', jira: 'Jira', jira_service_management: 'Jira Service Management', diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index cd6506d174c..389825b1477 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -163,7 +163,7 @@ export async function chunkedBatchDeleteByScope({ const result: TableCleanupResult = { table: tableName, deleted: 0, failed: 0 } if (scopeIds.length === 0) { - logger.info(`[${tableName}] Skipped — no resource owners in scope`) + logger.info('Skipped — no resource owners in scope', { tableName }) return result } @@ -220,8 +220,8 @@ export async function chunkedBatchDeleteByScope({ // Count rows we tried to delete; SELECT-stage errors leave rows=[]. result.failed += rows.length logger.error( - `[${tableName}] Batch failed (chunk ${chunkIdx + 1}/${chunks.length}, ${rows.length} rows):`, - { error } + `Batch failed (chunk ${chunkIdx + 1}/${chunks.length}, ${rows.length} rows):`, + { tableName, error } ) hasMore = false } @@ -229,7 +229,8 @@ export async function chunkedBatchDeleteByScope({ } logger.info( - `[${tableName}] Complete: ${result.deleted} deleted, ${result.failed} failed across ${chunks.length} chunks${stoppedEarly ? ' (row-limit reached, remaining chunks deferred to next run)' : ''}` + `Complete: ${result.deleted} deleted, ${result.failed} failed across ${chunks.length} chunks${stoppedEarly ? ' (row-limit reached, remaining chunks deferred to next run)' : ''}`, + { tableName } ) return result @@ -338,14 +339,15 @@ export async function deleteRowsById( // run picks up whatever's still expired, so this only inflates the metric. result.failed += chunkIds.length logger.error( - `[${tableName}] Delete chunk ${chunkIdx + 1}/${chunks.length} failed (up to ${chunkIds.length} rows):`, - { error } + `Delete chunk ${chunkIdx + 1}/${chunks.length} failed (up to ${chunkIds.length} rows):`, + { tableName, error } ) } } logger.info( - `[${tableName}] Deleted ${result.deleted} rows across ${chunks.length} chunk(s)${result.failed > 0 ? `, ${result.failed} failed` : ''}` + `Deleted ${result.deleted} rows across ${chunks.length} chunk(s)${result.failed > 0 ? `, ${result.failed} failed` : ''}`, + { tableName } ) return result } diff --git a/apps/sim/lib/cleanup/chat-cleanup.ts b/apps/sim/lib/cleanup/chat-cleanup.ts index 8de4de6ed36..110ad576ddf 100644 --- a/apps/sim/lib/cleanup/chat-cleanup.ts +++ b/apps/sim/lib/cleanup/chat-cleanup.ts @@ -110,9 +110,7 @@ export async function deleteStorageFiles( stats.filesDeleted += result.deleted stats.filesFailed += result.failed.length for (const { key, error } of result.failed) { - logger.error(`[${label}] Failed to delete storage file ${key} (context: ${context}):`, { - error, - }) + logger.error(`Failed to delete storage file ${key} (context: ${context}):`, { label, error }) } } @@ -131,7 +129,7 @@ export async function cleanupCopilotBackend( if (chatIds.length === 0 || !env.COPILOT_API_KEY) { if (!env.COPILOT_API_KEY) { - logger.warn(`[${label}] COPILOT_API_KEY not set, skipping copilot backend cleanup`) + logger.warn('COPILOT_API_KEY not set, skipping copilot backend cleanup', { label }) } return stats } @@ -150,7 +148,8 @@ export async function cleanupCopilotBackend( if (!response.ok) { const errorBody = await response.text().catch(() => '') - logger.error(`[${label}] Copilot backend cleanup failed: ${response.status}`, { + logger.error(`Copilot backend cleanup failed: ${response.status}`, { + label, errorBody, chatCount: chunk.length, }) @@ -161,11 +160,12 @@ export async function cleanupCopilotBackend( const result = await response.json() stats.deleted += result.deleted ?? 0 logger.info( - `[${label}] Copilot backend cleanup: ${result.deleted} chats deleted (batch ${Math.floor(i / COPILOT_CLEANUP_BATCH_SIZE) + 1})` + `Copilot backend cleanup: ${result.deleted} chats deleted (batch ${Math.floor(i / COPILOT_CLEANUP_BATCH_SIZE) + 1})`, + { label } ) } catch (error) { stats.failed += chunk.length - logger.error(`[${label}] Copilot backend cleanup request failed:`, { error }) + logger.error('Copilot backend cleanup request failed:', { label, error }) } } @@ -188,7 +188,8 @@ export async function prepareChatCleanup( // Collect file refs BEFORE DB deletion (keys + context are lost after cascade) const files = await collectChatFiles(chatIds) if (files.length > 0) { - logger.info(`[${label}] Collected ${files.length} files for cleanup`, { + logger.info(`Collected ${files.length} files for cleanup`, { + label, files: files.map((f) => ({ key: f.key, context: f.context })), }) } @@ -208,7 +209,8 @@ export async function prepareChatCleanup( } if (survivors.size > 0) { logger.info( - `[${label}] Skipping external cleanup for ${survivors.size} chats whose rows still exist` + `Skipping external cleanup for ${survivors.size} chats whose rows still exist`, + { label } ) } const confirmedChatIds = chatIds.filter((id) => !survivors.has(id)) @@ -220,7 +222,8 @@ export async function prepareChatCleanup( if (confirmedChatIds.length > 0) { const copilotResult = await cleanupCopilotBackend(confirmedChatIds, label) logger.info( - `[${label}] Copilot backend: ${copilotResult.deleted} deleted, ${copilotResult.failed} failed` + `Copilot backend: ${copilotResult.deleted} deleted, ${copilotResult.failed} failed`, + { label } ) await enqueueChatInlineImageCleanup(cleanupDb, confirmedChatIds) } @@ -229,7 +232,8 @@ export async function prepareChatCleanup( if (confirmedFiles.length > 0) { const fileStats = await deleteStorageFiles(confirmedFiles, label) logger.info( - `[${label}] Storage cleanup: ${fileStats.filesDeleted} deleted, ${fileStats.filesFailed} failed` + `Storage cleanup: ${fileStats.filesDeleted} deleted, ${fileStats.filesFailed} failed`, + { label } ) } }, diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index 7d904bff03b..1c9492af710 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -181,7 +181,7 @@ export const simProfile: CompetitorProfile = { value: 'Cloud-hosted (managed, multi-tenant SaaS) or self-hosted (Docker/Kubernetes). No documented managed single-tenant/VPC hosting tier in between', detail: - 'The Enterprise plan\'s only hosting-related row in the pricing comparison table is a boolean "Self Hosting" flag; there is no dedicated-instance/VPC offering.', + 'The Enterprise plan\'s only hosting-related row in the pricing comparison table is a boolean "Self-Hosted Enterprise Features" flag; there is no dedicated-instance/VPC offering.', shortValue: 'Cloud-hosted or self-hosted, no mid-tier VPC option', confidence: 'verified', sources: [ diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 972a220d5db..8ac12b0140b 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -340,10 +340,7 @@ export class DatabaseJobQueue implements JobQueueBackend { } return run().catch((err) => { if (controller.signal.aborted) return - logger.error(`[${type}] Inline run failed`, { - cancelKey, - error: toError(err).message, - }) + logger.error('Inline run failed', { jobType: type, cancelKey, error: toError(err).message }) }) }) try { @@ -628,11 +625,11 @@ export class DatabaseJobQueue implements JobQueueBackend { } catch (err) { if (abortController.signal.aborted) return const message = toError(err).message - logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) + logger.error(`Inline job ${jobId} failed`, { jobType: type, error: message }) try { await this.markJobFailed(jobId, message) } catch (markErr) { - logger.error(`[${type}] Failed to mark job ${jobId} as failed`, { markErr }) + logger.error(`Failed to mark job ${jobId} as failed`, { jobType: type, markErr }) } } finally { if (inlineAbortControllers.get(jobId) === abortController) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index dbc48cf9418..da8c2d61915 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -220,6 +220,8 @@ export const env = createEnv({ STRIPE_PRICE_TEAM_100_MO: z.string().min(1).optional(), // Team Max: $100/seat/mo STRIPE_PRICE_TEAM_100_YR: z.string().min(1).optional(), // Team Max: $1,020/seat/yr OVERAGE_THRESHOLD_DOLLARS: z.number().optional().default(100), // Dollar threshold for incremental overage billing (default: $100) + AWS_MARKETPLACE_PRODUCT_CODE: z.string().min(1).optional(), // AWS Marketplace SaaS product code; unset turns the Marketplace registration, claim, and reconcile surfaces off + AWS_MARKETPLACE_EVENTS_QUEUE_URL: z.string().url().optional(), // SQS queue receiving the Marketplace agreement EventBridge events the reconcile cron drains // Email & Communication EMAIL_VERIFICATION_ENABLED: z.boolean().optional(), // Enable email verification for user registration and login (defaults to false) @@ -366,6 +368,8 @@ export const env = createEnv({ AWS_ACCESS_KEY_ID: z.string().optional(), // AWS access key ID AWS_SECRET_ACCESS_KEY: z.string().optional(), // AWS secret access key TRIGGER_AWS_ROLE_ARN: z.string().optional(), // Role Trigger.dev runs assume through workload identity, instead of static keys + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: z.string().optional(), // Set by ECS: path of the task-role credential endpoint + AWS_CONTAINER_CREDENTIALS_FULL_URI: z.string().optional(), // Set by ECS Anywhere/EKS Pod Identity: full task-role credential endpoint URL S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 1c3a0439a8e..4e288028289 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -714,7 +714,10 @@ export class IdempotencyService { ) } - logger.warn(`Operation failed: ${claimResult.normalizedKey} - ${errorMessage}`) + logger.warn('Idempotent operation failed', { + key: claimResult.normalizedKey, + error: errorMessage, + }) throw error } } diff --git a/apps/sim/lib/core/outbox/handlers.ts b/apps/sim/lib/core/outbox/handlers.ts index 62f9094fe03..5cf607449a9 100644 --- a/apps/sim/lib/core/outbox/handlers.ts +++ b/apps/sim/lib/core/outbox/handlers.ts @@ -1,5 +1,9 @@ import { ADMIN_INVITATION_OPERATION_EVENT_TYPE } from '@/lib/admin/invitation-operation-event' import { ADMIN_MEMBER_OPERATION_EVENT_TYPE } from '@/lib/admin/member-operation-event' +import { + AWS_MARKETPLACE_BUYER_NOTICE_EVENT, + AWS_MARKETPLACE_TEAM_NOTICE_EVENT, +} from '@/lib/billing/aws-marketplace/notice-events' import { ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE, ENTERPRISE_MEMBER_RECONCILIATION_EVENT_TYPE, @@ -152,6 +156,15 @@ export const OUTBOX_HANDLER_GROUPS: readonly LazyOutboxHandlerGroup[] = [ return enterpriseOwnerClaimOutboxHandlers }, }, + { + events: [AWS_MARKETPLACE_TEAM_NOTICE_EVENT, AWS_MARKETPLACE_BUYER_NOTICE_EVENT], + load: async () => { + const { awsMarketplaceOutboxHandlers } = await import( + '@/lib/billing/aws-marketplace/notifications' + ) + return awsMarketplaceOutboxHandlers + }, + }, { events: [MIGRATED_INVITATION_EMAIL_EVENT_TYPE], load: async () => { diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts index d178946c0ba..0c052e038ab 100644 --- a/apps/sim/lib/core/security/deployment-auth.ts +++ b/apps/sim/lib/core/security/deployment-auth.ts @@ -31,7 +31,7 @@ export async function validateDeploymentAuth( sessionPresent = Boolean(session?.user) sessionEmail = session?.user?.email } catch (error) { - logger.error(`[${requestId}] Error validating SSO:`, error) + logger.error('Error validating SSO:', error, { requestId }) return { authorized: false, error: 'SSO authentication error' } } } diff --git a/apps/sim/lib/core/security/deployment-credentials.ts b/apps/sim/lib/core/security/deployment-credentials.ts index 1faa9f9858e..fa4b4fc7ff8 100644 --- a/apps/sim/lib/core/security/deployment-credentials.ts +++ b/apps/sim/lib/core/security/deployment-credentials.ts @@ -120,7 +120,7 @@ export async function validateDeploymentCredentials( } if (!resource.password) { - logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) + logger.error(`No password set for password-protected ${resource.id}`, { requestId }) return { authorized: false, error: 'Authentication configuration error' } } @@ -132,7 +132,8 @@ export async function validateDeploymentCredentials( { failClosed: true } ) if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { + logger.warn('Password attempt IP rate limit exceeded', { + requestId, resourceId: resource.id, cookiePrefix, ip, @@ -150,7 +151,8 @@ export async function validateDeploymentCredentials( { failClosed: true } ) if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { + logger.warn('Password attempt resource rate limit exceeded', { + requestId, resourceId: resource.id, cookiePrefix, }) @@ -167,7 +169,7 @@ export async function validateDeploymentCredentials( return { authorized: true } } catch (error) { - logger.error(`[${requestId}] Error validating password:`, error) + logger.error('Error validating password:', error, { requestId }) return { authorized: false, error: 'Authentication error' } } } @@ -198,7 +200,7 @@ export async function validateDeploymentCredentials( return { authorized: false, error: 'Email not authorized' } } catch (error) { - logger.error(`[${requestId}] Error validating email:`, error) + logger.error('Error validating email:', error, { requestId }) return { authorized: false, error: 'Authentication error' } } } @@ -224,7 +226,7 @@ export async function validateDeploymentCredentials( return { authorized: false, error: 'Your email is not authorized to access this resource' } } catch (error) { - logger.error(`[${requestId}] Error validating SSO:`, error) + logger.error('Error validating SSO:', error, { requestId }) return { authorized: false, error: 'SSO authentication error' } } } diff --git a/apps/sim/lib/core/utils/request.ts b/apps/sim/lib/core/utils/request.ts index a32a7b84e68..683d48fe934 100644 --- a/apps/sim/lib/core/utils/request.ts +++ b/apps/sim/lib/core/utils/request.ts @@ -10,8 +10,8 @@ export const trustedProxies = clientIpResolver.trustedProxies /** * Generate a short request ID for correlation. If called inside a request * context (see `withRouteHandler` and `runWithRequestContext`), returns the - * active request's ID so inline `[${requestId}]` log prefixes align with - * the auto-attached `{requestId=...}` logger metadata. + * active request's ID, the same one the logger stamps on every line, so work + * handed this id correlates with the request that started it. */ export function generateRequestId(): string { return getRequestContext()?.requestId ?? generateId().slice(0, 8) diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index f41593c1761..5a5f1b93eaf 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -23,6 +23,13 @@ const COMMON_TIMEZONES = [ 'Australia/Sydney', ] +/** A bare `yyyy-MM-dd` that survives a calendar round-trip, so `2026-02-31` is not a date. */ +export function isCalendarDate(value: string | null): value is string { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const instant = new Date(`${value}T00:00:00.000Z`) + return !Number.isNaN(instant.getTime()) && instant.toISOString().slice(0, 10) === value +} + /** A wall-clock reading of an instant in some timezone. */ export interface WallClockParts { year: number diff --git a/apps/sim/lib/credential-groups/README.md b/apps/sim/lib/credential-groups/README.md index e586cf2e8d1..a0a5b19e9d1 100644 --- a/apps/sim/lib/credential-groups/README.md +++ b/apps/sim/lib/credential-groups/README.md @@ -10,7 +10,7 @@ Credential Groups remains available whenever its organization feature is enabled An allowed workspace grants every normally authorized manual and deployed workflow access to every active contribution in this pool. There is no per-workflow resource-policy grant and no per-person filtering for workflow execution. Keep ordinary workspace/workflow authorization and deployment authority: an allowlist entry alone cannot authorize running a workflow. Nested workflows use their actual execution workspace. A workspace move, revocation, inactive enrollment, removed provider, disabled group, or unavailable org entitlement blocks subsequent use. -Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified Sim email matching the invitation; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. People can connect any provider account they can authorize, even when its email differs from their Sim or invitation email. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's Search documents. +Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified Sim email matching the invitation; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. People can connect any provider account they can authorize, even when its email differs from their Sim or invitation email. One person may connect several accounts of a provider from **Integrations**, identified by the provider's subject and tenant, up to five live accounts per provider. This applies to every OAuth provider; Slack accounts must belong to the organization's configured workspace. Reconnect stays bound to the account it was started from; disconnecting releases the slot, and the same identity returning restores its credential. An invitation link never adds an account beside a live one; it refreshes that account, or connects one when none is live. Managed MCP connections follow the same rules for connectors with a vendor-documented identity tool (Notion, Granola, Fireflies, Coda, HubSpot): the account is read through that tool during OAuth completion. A connection made before this is identified through its stored tokens when another account is added; if that fails it keeps working but blocks adding another until it is reconnected. Zoom, Lucid, and Databricks have no such tool and stay at one per server. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's Search documents. Disconnect revokes the local grant and invalidates pending invitation-based authorization. Administrators can revoke an enrollment; the person cannot restore it themselves. Removing workspace access stops future authorized calls, but cannot recall a provider request already in flight or erase data already returned to a workflow. Full-pool sharing includes public, scheduled, and webhook deployments that otherwise pass workflow authorization. @@ -20,9 +20,9 @@ Disconnect revokes the local grant and invalidates pending invitation-based auth | --- | --- | --- | | Select Credential | Workspace OAuth credential | Existing credential reference | | List Credentials | Optional workspace provider filter | Existing reference list | -| Find Organization Account | Email and OAuth provider | Exactly one reference, otherwise an error | +| Find Organization Account | Email and OAuth provider | The person's first connected account for that provider, otherwise an error | | List Organization Accounts | Optional email/providers, limit/cursor | Bounded reference page | -| Find Organization MCP Connection | Email and MCP provider | Exactly one personal MCP reference, otherwise an error | +| Find Organization MCP Connection | Email and MCP provider | The person's first connected MCP reference for that provider, otherwise an error | | List Organization MCP Connections | Optional email/provider, limit/cursor | Bounded MCP reference page | MCP `credentialId` identifies the person’s connection (`mcp-cg-…`) and is used to select the connection in the MCP block. `mcpServerId` identifies shared configuration and does not grant access to a person’s token. The block never returns secrets or invitation links. Org operations are hidden in ineligible workspaces; saved invalid configurations fail during execution. diff --git a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts new file mode 100644 index 00000000000..602bc70e6db --- /dev/null +++ b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts @@ -0,0 +1,669 @@ +/** + * One member connecting several accounts of one provider, against real Postgres so the unique + * index and the serialized grant persistence are exercised, not a mock of them. Only the provider + * exchange is faked: the authorization code names the identity the provider would have verified. + */ +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + mcpServers, + member, + organization, + user, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import { listCredentialGroupEnrollments } from '@/lib/credential-groups/enrollments' +import { identifyStoredManagedMcpConnections } from '@/lib/credential-groups/mcp-oauth' +import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' +import { decryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth' +import { resolveSlackSearchMember } from '@/lib/knowledge/application/slack-search/identity' +import { mcpService } from '@/lib/mcp/service' + +const POLICY = { + authorizationAppId: 'fixture-app', + requiredScopes: ['read'], + scopeVersion: 1, +} + +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: async () => true, +})) + +vi.mock('@/lib/credential-groups/provider-registry', async (importOriginal) => { + const { getCredentialGroupProviderId } = await import('@/lib/credential-groups/providers') + return { + ...(await importOriginal()), + getCredentialGroupProviderAdapter: (provider: CredentialGroupProvider) => { + const policy = { + ...POLICY, + provider, + providerId: getCredentialGroupProviderId(provider), + } + return { + provider, + requiresRefreshToken: false, + getPolicy: async () => policy, + exchangeAndVerify: async ({ code }: { code: string }) => { + const [subject, tenant] = code.split('@') + return { + providerId: policy.providerId, + providerSubjectId: subject, + providerTenantId: tenant || null, + displayName: code, + metadata: { email: code }, + accessToken: `token-for-${code}`, + grantedScopes: POLICY.requiredScopes, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + } + }, + } + }, + } +}) + +const organizationId = generateId() +const userId = generateId() +const groupId = generateId() +const enrollmentId = generateId() +let invitationToken = generateId() +const mcpServerId = generateId() +const email = `${userId}@example.test` + +const optionFor = (provider: CredentialGroupProvider) => ({ + id: `option-${provider}`, + provider, + label: provider, + authorizationAppId: POLICY.authorizationAppId, + requiredScopes: POLICY.requiredScopes, + scopeVersion: POLICY.scopeVersion, + required: false, + status: 'active' as const, +}) +const DRIVE = optionFor('google-drive') +const GITHUB = optionFor('github-repositories') +const SLACK = optionFor('slack') + +function connect( + code: string, + connectionIntent?: CredentialGroupOAuthAttempt['connectionIntent'], + option = DRIVE +) { + const context: CredentialGroupOAuthContext = { + enrollmentId, + credentialGroupId: groupId, + credentialGroupName: 'Connected accounts', + organizationId, + workspaceName: 'Fixture', + workspaceOwnerId: null, + credentialOwnerId: userId, + email, + enrollmentStatus: 'completed', + option, + options: [DRIVE, GITHUB, SLACK], + } + const attempt: CredentialGroupOAuthAttempt = { + userId, + state: generateId(), + provider: option.provider as CredentialGroupProvider, + nonceHash: 'nonce', + organizationId, + email, + enrollmentId, + credentialGroupId: groupId, + optionId: option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.test/callback', + invitationToken, + createdAt: Date.now(), + ...(connectionIntent ? { connectionIntent } : {}), + } + return completeCredentialGroupOAuth(context, attempt, code) +} + +async function failureOf(result: Promise) { + const error = await result.then( + () => null, + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(CredentialGroupOAuthError) + return (error as CredentialGroupOAuthError).failure +} + +const rows = () => + db + .select({ + id: credential.id, + subject: credential.providerSubjectId, + tenant: credential.providerTenantId, + status: credential.managedOauthStatus, + revokedAt: credential.revokedAt, + tokens: credential.encryptedOauthTokenSet, + }) + .from(credential) + .where(eq(credential.organizationId, organizationId)) + +/** Mirrors the disconnect operation, which also invalidates the outstanding invitation token. */ +async function disconnect(id: string) { + await db + .update(credential) + .set({ managedOauthStatus: 'revoked', revokedAt: new Date() }) + .where(eq(credential.id, id)) + invitationToken = generateId() + await db + .update(credentialGroupEnrollment) + .set({ invitationTokenHash: sha256Hex(invitationToken) }) + .where(eq(credentialGroupEnrollment.id, enrollmentId)) +} + +beforeAll(async () => { + await db.insert(user).values({ + id: userId, + name: 'Multiple accounts fixture', + email, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values({ + id: organizationId, + name: 'Multiple accounts fixture', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(credentialGroup).values({ + id: groupId, + organizationId, + publicId: generateId(), + name: 'Connected accounts', + options: [DRIVE, GITHUB, SLACK], + }) + await db.insert(member).values({ + id: generateId(), + organizationId, + userId, + role: 'member', + createdAt: new Date(), + }) + await db.insert(mcpServers).values({ + id: mcpServerId, + organizationId, + credentialGroupId: groupId, + managedConnectorId: 'notion', + name: 'Notion', + transport: 'streamable-http', + url: 'https://mcp.notion.com/mcp', + authType: 'oauth', + oauthConfigVersion: 1, + enabled: true, + createdBy: userId, + }) + await db.insert(credentialGroupEnrollment).values({ + id: enrollmentId, + credentialGroupId: groupId, + userId, + email, + status: 'completed', + invitationTokenHash: sha256Hex(invitationToken), + invitationExpiresAt: new Date(Date.now() + 3_600_000), + invitedAt: new Date(), + createdBy: userId, + }) +}) + +beforeEach(async () => { + await db.delete(credential).where(eq(credential.organizationId, organizationId)) +}) + +afterAll(async () => { + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(eq(user.id, userId)) +}) + +describe('connecting several accounts of one provider', () => { + it('adds a second identity as its own credential and leaves the first untouched', async () => { + const first = await connect('work@first-tenant.test', { kind: 'create' }) + const [before] = await rows() + + const second = await connect('work@second-tenant.test', { kind: 'create' }) + + expect(second.created).toBe(true) + expect(second.credentialId).not.toBe(first.credentialId) + const after = await rows() + expect(after).toHaveLength(2) + expect(after.find((row) => row.id === first.credentialId)?.tokens).toBe(before?.tokens) + const stored = after.find((row) => row.id === second.credentialId) + expect(stored).toMatchObject({ + subject: 'work', + tenant: 'second-tenant.test', + status: 'active', + }) + expect(await decryptManagedOAuthTokenSet(stored?.tokens ?? '')).toMatchObject({ + accessToken: 'token-for-work@second-tenant.test', + }) + }) + + it('treats one subject in two tenants as two accounts', async () => { + await connect('same@tenant-a.test', { kind: 'create' }) + await connect('same@tenant-b.test', { kind: 'create' }) + expect(await rows()).toHaveLength(2) + }) + + it('refuses to add an identity that is already connected, changing nothing', async () => { + await connect('work@first-tenant.test', { kind: 'create' }) + const before = await rows() + + expect(await failureOf(connect('work@first-tenant.test', { kind: 'create' }))).toBe( + 'account_already_connected' + ) + expect(await rows()).toEqual(before) + }) + + it('stops adding at the per-provider limit, without counting disconnected accounts', async () => { + const created = [] + for (let index = 0; index < 5; index += 1) + created.push(await connect(`user-${index}@tenant.test`, { kind: 'create' })) + + expect(await failureOf(connect('user-5@tenant.test', { kind: 'create' }))).toBe( + 'account_limit_reached' + ) + expect(await rows()).toHaveLength(5) + + await disconnect(created[0]?.credentialId ?? '') + await connect('user-5@tenant.test', { kind: 'create' }) + expect(await rows()).toHaveLength(6) + }) + + it('never creates two rows when the same identity completes twice at once', async () => { + const results = await Promise.allSettled([ + connect('work@first-tenant.test', { kind: 'create' }), + connect('work@first-tenant.test', { kind: 'create' }), + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(await rows()).toHaveLength(1) + }) +}) + +describe('reconnecting one of several accounts', () => { + it('refreshes only the targeted credential', async () => { + const first = await connect('work@first-tenant.test', { kind: 'create' }) + const second = await connect('work@second-tenant.test', { kind: 'create' }) + const before = await rows() + + const reconnected = await connect('work@second-tenant.test', { + kind: 'reconnect', + credentialId: second.credentialId, + }) + + expect(reconnected).toMatchObject({ created: false, credentialId: second.credentialId }) + const after = await rows() + const untouched = (list: typeof after) => list.find((row) => row.id === first.credentialId) + expect(untouched(after)?.tokens).toBe(untouched(before)?.tokens) + const targeted = (list: typeof after) => list.find((row) => row.id === second.credentialId) + expect(targeted(after)?.tokens).not.toBe(targeted(before)?.tokens) + expect(after).toHaveLength(2) + }) + + it('refuses a different identity, including another connected account of the member', async () => { + const first = await connect('work@first-tenant.test', { kind: 'create' }) + await connect('work@second-tenant.test', { kind: 'create' }) + const before = await rows() + + const intent = { kind: 'reconnect', credentialId: first.credentialId } as const + expect(await failureOf(connect('work@second-tenant.test', intent))).toBe('account_mismatch') + expect(await failureOf(connect('stranger@other.test', intent))).toBe('account_mismatch') + expect(await rows()).toEqual(before) + }) + + it('refuses a reconnect target that belongs to another option', async () => { + await connect('work@first-tenant.test', { kind: 'create' }) + const other = await connect('work@first-tenant.test', { kind: 'create' }, GITHUB) + await expect( + connect('work@first-tenant.test', { kind: 'reconnect', credentialId: other.credentialId }) + ).rejects.toThrow('account changed') + expect(await rows()).toHaveLength(2) + }) +}) + +describe('adding an account that needs reconnecting', () => { + it('refreshes it instead of calling it already connected', async () => { + const first = await connect('work@first-tenant.test', { kind: 'create' }) + await db + .update(credential) + .set({ managedOauthStatus: 'needs_reauth' }) + .where(eq(credential.id, first.credentialId)) + + const refreshed = await connect('work@first-tenant.test', { kind: 'create' }) + + expect(refreshed).toMatchObject({ created: false, credentialId: first.credentialId }) + expect(await rows()).toMatchObject([{ status: 'active' }]) + }) +}) + +describe('after disconnecting', () => { + it('does not let reconnecting a disconnected account exceed the limit', async () => { + const created = [] + for (let index = 0; index < 5; index += 1) + created.push(await connect(`user-${index}@tenant.test`, { kind: 'create' })) + const disconnected = created[0]?.credentialId ?? '' + await disconnect(disconnected) + await connect('replacement@tenant.test', { kind: 'create' }) + + expect( + await failureOf( + connect('user-0@tenant.test', { kind: 'reconnect', credentialId: disconnected }) + ) + ).toBe('account_limit_reached') + expect((await rows()).filter((row) => !row.revokedAt)).toHaveLength(5) + }) + + it('lets the member connect a different account', async () => { + const first = await connect('work@first-tenant.test', { kind: 'create' }) + await disconnect(first.credentialId) + + const replacement = await connect('work@second-tenant.test', { kind: 'create' }) + + expect(replacement.created).toBe(true) + const after = await rows() + expect(after.find((row) => row.id === first.credentialId)?.status).toBe('revoked') + expect(after.find((row) => row.id === replacement.credentialId)?.status).toBe('active') + }) + + it('restores the same credential when the same identity returns', async () => { + const first = await connect('work@first-tenant.test', { kind: 'create' }) + await disconnect(first.credentialId) + + const restored = await connect('work@first-tenant.test', { kind: 'create' }) + + expect(restored).toMatchObject({ created: false, credentialId: first.credentialId }) + expect(await rows()).toHaveLength(1) + expect((await rows())[0]).toMatchObject({ status: 'active', revokedAt: null }) + }) +}) + +describe('resolving a Slack sender for a member with several Slack accounts', () => { + const resolve = (slackUserId: string) => + resolveSlackSearchMember(organizationId, 'T1', slackUserId, email) + + it('accepts each linked account and refuses a sender linked to neither', async () => { + await connect('U1@T1', { kind: 'create' }, SLACK) + await connect('U2@T1', { kind: 'create' }, SLACK) + + await expect(resolve('U1')).resolves.toBe(userId) + await expect(resolve('U2')).resolves.toBe(userId) + await expect(resolve('U3')).rejects.toThrow('verified Sim account') + }) + + it('still accepts a member with no linked Slack account', async () => { + await expect(resolve('U1')).resolves.toBe(userId) + }) +}) + +describe('invitation links, which carry no connection intent', () => { + it('refresh the matching identity', async () => { + const first = await connect('work@first-tenant.test') + const again = await connect('work@first-tenant.test') + expect(again).toMatchObject({ created: false, credentialId: first.credentialId }) + }) + + it('refuse to silently add a second account beside a live one', async () => { + await connect('work@first-tenant.test') + expect(await failureOf(connect('work@second-tenant.test'))).toBe('account_mismatch') + expect(await rows()).toHaveLength(1) + }) +}) + +describe('managed MCP accounts', () => { + const identityOf = (name: string) => ({ + subject: name, + tenant: 'workspace-1', + label: `${name}@example.test`, + }) + const connectMcp = ( + identity: ReturnType | null, + connectionIntent?: CredentialGroupOAuthAttempt['connectionIntent'] + ) => + persistManagedMcpCredential({ + invitationTokenHash: sha256Hex(invitationToken), + oauthConfigVersion: 1, + enrollmentId, + credentialGroupId: groupId, + email, + userId, + organizationId, + mcpServerId, + mcpServerName: 'Notion', + tokens: { access_token: `token-${identity?.subject ?? 'anonymous'}`, token_type: 'bearer' }, + tools: [], + identity, + connectionIntent, + }) + const mcpRows = () => + db + .select({ + id: credential.id, + subject: credential.providerSubjectId, + displayName: credential.displayName, + status: credential.managedOauthStatus, + }) + .from(credential) + .where(eq(credential.mcpServerId, mcpServerId)) + + it('adds a second identity as its own connection, named for the account', async () => { + const first = await connectMcp(identityOf('one'), { kind: 'create' }) + const second = await connectMcp(identityOf('two'), { kind: 'create' }) + + expect(second.created).toBe(true) + expect(second.connectionId).not.toBe(first.connectionId) + expect((await mcpRows()).map((row) => row.displayName).sort()).toEqual([ + 'one@example.test', + 'two@example.test', + ]) + }) + + it('refuses an identity that is already connected, and stops at the limit', async () => { + await connectMcp(identityOf('one'), { kind: 'create' }) + expect(await failureOf(connectMcp(identityOf('one'), { kind: 'create' }))).toBe( + 'account_already_connected' + ) + for (const name of ['two', 'three', 'four', 'five']) + await connectMcp(identityOf(name), { kind: 'create' }) + expect(await failureOf(connectMcp(identityOf('six'), { kind: 'create' }))).toBe( + 'account_limit_reached' + ) + expect(await mcpRows()).toHaveLength(5) + }) + + it('pins reconnect to its account', async () => { + const first = await connectMcp(identityOf('one'), { kind: 'create' }) + await connectMcp(identityOf('two'), { kind: 'create' }) + const intent = { kind: 'reconnect', credentialId: first.connectionId } as const + + expect(await failureOf(connectMcp(identityOf('two'), intent))).toBe('account_mismatch') + await expect(connectMcp(identityOf('one'), intent)).resolves.toMatchObject({ + created: false, + connectionId: first.connectionId, + }) + expect(await failureOf(connectMcp(null, intent))).toBe('account_unverified') + }) + + it('keeps the People list working when someone has several accounts of one server', async () => { + await connectMcp(identityOf('one'), { kind: 'create' }) + await connectMcp(identityOf('two'), { kind: 'create' }) + + const { enrollments } = await listCredentialGroupEnrollments( + { kind: 'organization', organizationId }, + groupId, + 10 + ) + + expect(enrollments[0]?.mcpConnections).toEqual([ + { mcpServerId, name: 'Notion', status: 'active', count: 2 }, + ]) + }) + + it('reconnects a lone account when the provider cannot identify it, keeping its identity', async () => { + const only = await connectMcp(identityOf('one'), { kind: 'create' }) + + await expect( + connectMcp(null, { kind: 'reconnect', credentialId: only.connectionId }) + ).resolves.toMatchObject({ created: false, connectionId: only.connectionId }) + await expect(connectMcp(null)).resolves.toMatchObject({ connectionId: only.connectionId }) + + expect(await mcpRows()).toMatchObject([{ subject: 'one', displayName: 'one@example.test' }]) + }) + + it('still connects a first account when the provider cannot identify it', async () => { + await expect(connectMcp(null, { kind: 'create' })).resolves.toMatchObject({ created: true }) + expect(await mcpRows()).toMatchObject([{ subject: null, displayName: 'Notion' }]) + }) + + it('refuses a second account while it cannot tell them apart', async () => { + await connectMcp(identityOf('one'), { kind: 'create' }) + expect(await failureOf(connectMcp(null, { kind: 'create' }))).toBe('account_unverified') + expect(await mcpRows()).toHaveLength(1) + }) + + describe('with a connection made before accounts were identified', () => { + const identifyStored = (result: unknown) => { + const call = vi.spyOn(mcpService, 'executeManagedMcpTool').mockImplementation(async () => { + if (result instanceof Error) throw result + return result as Awaited> + }) + return identifyStoredManagedMcpConnections({ + organizationId, + enrollmentId, + server: { id: mcpServerId, connectorId: 'notion' }, + }).finally(() => call.mockRestore()) + } + const notionSelf = (name: string) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + workspace: { id: 'workspace-1', name: 'Fixture' }, + user: { type: 'person', id: name, email: `${name}@example.test` }, + }), + }, + ], + }) + + it('identifies it from its stored tokens so another account can be added directly', async () => { + const legacy = await connectMcp(null, { kind: 'create' }) + + await identifyStored(notionSelf('one')) + + expect(await mcpRows()).toMatchObject([ + { id: legacy.connectionId, subject: 'one', displayName: 'one@example.test · Fixture' }, + ]) + await expect(connectMcp(identityOf('two'), { kind: 'create' })).resolves.toMatchObject({ + created: true, + }) + }) + + it('recognizes the same account being added again', async () => { + await connectMcp(null, { kind: 'create' }) + await identifyStored(notionSelf('one')) + expect(await failureOf(connectMcp(identityOf('one'), { kind: 'create' }))).toBe( + 'account_already_connected' + ) + }) + + it('keeps the connection unidentified when its tokens no longer work', async () => { + await connectMcp(null, { kind: 'create' }) + await identifyStored(new Error('token expired')) + expect(await mcpRows()).toMatchObject([{ subject: null, displayName: 'Notion' }]) + expect(await failureOf(connectMcp(identityOf('two'), { kind: 'create' }))).toBe( + 'account_identity_required' + ) + }) + + it('requires reconnecting it before adding another, then allows both', async () => { + const legacy = await connectMcp(null, { kind: 'create' }) + + expect(await failureOf(connectMcp(identityOf('two'), { kind: 'create' }))).toBe( + 'account_identity_required' + ) + + const reconnected = await connectMcp(identityOf('one'), { + kind: 'reconnect', + credentialId: legacy.connectionId, + }) + expect(reconnected.connectionId).toBe(legacy.connectionId) + expect(await mcpRows()).toMatchObject([{ subject: 'one', displayName: 'one@example.test' }]) + + await connectMcp(identityOf('two'), { kind: 'create' }) + expect(await mcpRows()).toHaveLength(2) + }) + + it('keeps its id when disconnected and connected again, so workflows pinned to it survive', async () => { + const legacy = await connectMcp(null, { kind: 'create' }) + await disconnect(legacy.connectionId) + + await expect(connectMcp(identityOf('one'), { kind: 'create' })).resolves.toMatchObject({ + created: false, + connectionId: legacy.connectionId, + }) + expect(await mcpRows()).toMatchObject([{ id: legacy.connectionId, subject: 'one' }]) + }) + + it('refuses to adopt an identity another connection already holds', async () => { + const held = await connectMcp(identityOf('one'), { kind: 'create' }) + await disconnect(held.connectionId) + const legacy = await connectMcp(null, { kind: 'create' }) + + expect( + await failureOf( + connectMcp(identityOf('one'), { kind: 'reconnect', credentialId: legacy.connectionId }) + ) + ).toBe('account_mismatch') + expect(await failureOf(connectMcp(identityOf('one')))).toBe('account_mismatch') + }) + + it('fills in the identity through an invitation link', async () => { + const legacy = await connectMcp(null) + await expect(connectMcp(identityOf('one'))).resolves.toMatchObject({ + created: false, + connectionId: legacy.connectionId, + }) + expect(await mcpRows()).toMatchObject([{ subject: 'one', displayName: 'one@example.test' }]) + }) + }) + + it('lets a disconnected slot go to a different account, and restores a returning one', async () => { + const first = await connectMcp(identityOf('one'), { kind: 'create' }) + await disconnect(first.connectionId) + + const replacement = await connectMcp(identityOf('two'), { kind: 'create' }) + expect(replacement.created).toBe(true) + + const restored = await connectMcp(identityOf('one'), { kind: 'create' }) + expect(restored).toMatchObject({ created: false, connectionId: first.connectionId }) + }) + + it('does not let an invitation link add a second account', async () => { + await connectMcp(identityOf('one')) + expect(await failureOf(connectMcp(identityOf('two')))).toBe('account_mismatch') + expect(await mcpRows()).toHaveLength(1) + }) + + it('never creates two connections when one identity completes twice at once', async () => { + await Promise.allSettled([ + connectMcp(identityOf('one'), { kind: 'create' }), + connectMcp(identityOf('one'), { kind: 'create' }), + ]) + expect(await mcpRows()).toHaveLength(1) + }) +}) diff --git a/apps/sim/lib/credential-groups/account-rules.test.ts b/apps/sim/lib/credential-groups/account-rules.test.ts new file mode 100644 index 00000000000..96708cb8a10 --- /dev/null +++ b/apps/sim/lib/credential-groups/account-rules.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { moreUsableAccount } from '@/lib/credential-groups/account-rules' + +const row = (status: string | null) => ({ status }) +const pick = (...statuses: Array) => + statuses + .map(row) + .reduce | undefined>( + (best, candidate) => moreUsableAccount(best, candidate, (account) => account.status), + undefined + )?.status + +describe('choosing which of several accounts represents a connection', () => { + it.each([ + [['revoked', 'active'], 'active'], + [['active', 'revoked'], 'active'], + [['revoked', 'needs_reauth'], 'needs_reauth'], + [['needs_reauth', 'revoked'], 'needs_reauth'], + [['needs_reauth', 'active', 'revoked'], 'active'], + [[null, 'revoked'], null], + ])('%j resolves to %s', (statuses, expected) => { + expect(pick(...statuses)).toBe(expected) + }) +}) diff --git a/apps/sim/lib/credential-groups/account-rules.ts b/apps/sim/lib/credential-groups/account-rules.ts new file mode 100644 index 00000000000..ef4fea3b4a2 --- /dev/null +++ b/apps/sim/lib/credential-groups/account-rules.ts @@ -0,0 +1,105 @@ +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + type CredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' +import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' + +interface ConnectedAccount { + id: string + revokedAt: Date | null + managedOauthStatus: string | null + providerSubjectId: string | null + providerTenantId: string | null +} + +function accountConflict(failure: CredentialGroupOAuthFailure) { + return new CredentialGroupOAuthError( + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure], + 409, + undefined, + failure + ) +} + +/** + * Applies what the member asked for to the account the provider verified, returning the row to + * refresh or `undefined` to add one. Only an explicit add can grow the set. A managed MCP row or + * identity may be unknown. A lone account still reconnects, as it always could, but nothing is added + * beside an unknown account, since the two could be the same one. A disconnected unknown row is + * reused so tool references pinned to its id keep working. + */ +export function resolveAccountWrite({ + intent, + limit, + accounts, + identity, +}: { + intent?: CredentialGroupConnectionIntent + limit: number + accounts: Account[] + identity: { subject: string; tenant: string | null } | null +}): Account | undefined { + const existing = identity + ? accounts.find( + (account) => + account.providerSubjectId === identity.subject && + account.providerTenantId === identity.tenant + ) + : undefined + const live = accounts.filter((account) => !account.revokedAt) + const hasUnidentifiedLive = live.some((account) => account.providerSubjectId === null) + const disconnectedUnidentified = accounts.find( + (account) => account.revokedAt && account.providerSubjectId === null + ) + + if (intent?.kind === 'reconnect') { + const target = accounts.find((account) => account.id === intent.credentialId) + if (!target) + throw new CredentialGroupOAuthError( + 'The account changed during authorization. Refresh your connections and try again.', + 409 + ) + if (identity) { + if (existing ? existing.id !== target.id : target.providerSubjectId !== null) + throw accountConflict('account_mismatch') + } else if ( + target.providerSubjectId !== null && + live.some((account) => account.id !== target.id) + ) { + throw accountConflict('account_unverified') + } + if (target.revokedAt && live.length >= limit) throw accountConflict('account_limit_reached') + return target + } + + if (intent?.kind === 'create') { + if (existing && !existing.revokedAt) { + if (existing.managedOauthStatus === 'active') + throw accountConflict('account_already_connected') + return existing + } + if (live.length > 0 && !identity) throw accountConflict('account_unverified') + if (hasUnidentifiedLive) throw accountConflict('account_identity_required') + if (live.length >= limit) throw accountConflict('account_limit_reached') + return existing ?? (live.length === 0 ? disconnectedUnidentified : undefined) + } + + if (existing && !existing.revokedAt) return existing + if (live.length === 0) return existing ?? disconnectedUnidentified + if (live.length === 1 && !existing && (!identity || hasUnidentifiedLive)) return live[0] + throw accountConflict(identity ? 'account_mismatch' : 'account_unverified') +} + +const ACCOUNT_STATUS_RANK: Record = { active: 2, needs_reauth: 1 } + +/** The account that best represents a connection: usable, then fixable by reconnecting. */ +export function moreUsableAccount( + current: Account | undefined, + candidate: Account, + statusOf: (account: Account) => string | null +): Account { + if (current === undefined) return candidate + const rank = (account: Account) => ACCOUNT_STATUS_RANK[statusOf(account) ?? ''] ?? 0 + return rank(candidate) > rank(current) ? candidate : current +} 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 49fe9deaab7..9f0ead2c1c5 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.test.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -188,6 +188,7 @@ describe('organization workspace sharing administration', () => { status: 'needs_reauth', } queueTableRows(schemaMock.credential, []) + queueTableRows(schemaMock.credential, []) queueTableRows(schemaMock.credential, [account]) const result = await getOrganizationAccountsSettings.execute({ principal, diff --git a/apps/sim/lib/credential-groups/application/organization-accounts.ts b/apps/sim/lib/credential-groups/application/organization-accounts.ts index 85dfb998d67..707c82e62d3 100644 --- a/apps/sim/lib/credential-groups/application/organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/organization-accounts.ts @@ -2,7 +2,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' import { credentialGroup as credentialGroupTable } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import type { StartOrganizationAccountConnectionBody } from '@/lib/api/contracts/organization-accounts' +import type { + OrganizationAccountsSettings, + StartOrganizationAccountConnectionBody, +} from '@/lib/api/contracts/organization-accounts' import { loadAssistantIntegrationTools } from '@/lib/assistant-actions/catalog' import type { OperationUseCase } from '@/lib/core/application/operation' import { @@ -49,6 +52,7 @@ import { listViewerOrganizationMcpAccounts, } from '@/lib/credential-groups/viewer-accounts' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' +import { listSavedSearchCredentials } from '@/lib/sim-search/live/accounts' export const organizationAccountOperations = { read: defineOrganizationOperation({ @@ -172,6 +176,23 @@ export const getOrganizationAccountsSettings = defineOrganizationAccountsUseCase const credentialGroup = await getOrganizationAccountsGroup(context.organizationId) return { credentialGroup, + viewerSavedAccounts: ( + await listSavedSearchCredentials( + { kind: 'organization', organizationId: context.organizationId }, + context.userId + ) + ).flatMap[number]>((row) => + row.providerId === 'jama-service-account' || row.providerId === 'flow-service-account' + ? [ + { + credentialId: row.id, + displayName: row.displayName, + providerId: row.providerId, + status: 'active' as const, + }, + ] + : [] + ), viewerMcpAccounts: credentialGroup ? await listViewerOrganizationMcpAccounts({ organizationId: context.organizationId, @@ -281,6 +302,7 @@ export const startOrganizationAccountConnection = defineOrganizationAccountsUseC mcpServerId: input.mcpServerId, completionId: input.oauthCompletionId, returnTo: input.returnTo, + connectionIntent: input.connectionIntent ?? { kind: 'create' }, }) } const { invitationLink } = await createViewerCredentialGroupEnrollment({ @@ -306,7 +328,7 @@ export const startOrganizationAccountConnection = defineOrganizationAccountsUseC optionId: input.optionId, completionId: input.oauthCompletionId, returnTo: input.returnTo, - connectionIntent: input.connectionIntent, + connectionIntent: input.connectionIntent ?? { kind: 'create' }, }) } const { invitationLink } = await createViewerCredentialGroupEnrollment({ 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 ade2baabfa7..a46adaac2ec 100644 --- a/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts @@ -169,6 +169,7 @@ export const reconnectPersonalOrganizationAccount = defineAuthorizedCredentialUs return startViewerCredentialGroupMcpOAuth({ ...connection, mcpServerId: account.mcpServerId, + connectionIntent: { kind: 'reconnect', credentialId: account.credentialId }, }) } throw new OrchestrationError('not_found', 'This account provider is no longer available') diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index d0f0e44a3df..594ab5910c1 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -358,7 +358,8 @@ export const completePublicCredentialGroupMcpOAuth = input.attempt.codeVerifier, input.code, input.attempt.invitationToken, - input.attempt.configurationFingerprint + input.attempt.configurationFingerprint, + input.attempt.connectionIntent ) if (context.organizationId) await fireCredentialGroupTrigger({ @@ -375,7 +376,7 @@ export const completePublicCredentialGroupMcpOAuth = mcpServerId: completion.mcpServerId, provider: context.oauth.server.connectorId, providerId: context.oauth.server.connectorId, - displayName: context.oauth.server.name, + displayName: completion.displayName, }, }) return { connectionId: completion.connectionId, mcpServerId: completion.mcpServerId } diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 90f8fdc3459..7c12d0feb7e 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -25,6 +25,7 @@ import { } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { moreUsableAccount } from '@/lib/credential-groups/account-rules' import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' @@ -804,7 +805,7 @@ export async function listCredentialGroupEnrollments( if (connectionRows.length > connectionSummaryLimit) { throw new Error('Managed credential connection summaries exceed the supported provider states') } - const mcpConnectionSummaryLimit = enrollmentIds.length * activeMcpServers.length + const mcpConnectionSummaryLimit = enrollmentIds.length * activeMcpServers.length * 3 const mcpConnectionRows = enrollmentIds.length === 0 || activeMcpServers.length === 0 ? [] @@ -813,6 +814,7 @@ export async function listCredentialGroupEnrollments( enrollmentId: credential.credentialGroupEnrollmentId, mcpServerId: credential.mcpServerId, status: credential.managedOauthStatus, + count: count(credential.id), }) .from(credential) .where( @@ -825,6 +827,11 @@ export async function listCredentialGroupEnrollments( ) ) ) + .groupBy( + credential.credentialGroupEnrollmentId, + credential.mcpServerId, + credential.managedOauthStatus + ) .limit(mcpConnectionSummaryLimit + 1) if (mcpConnectionRows.length > mcpConnectionSummaryLimit) { throw new Error('Managed MCP connection summaries exceed the linked server limit') @@ -854,6 +861,7 @@ export async function listCredentialGroupEnrollments( mcpServerId: server.id, name: server.name, status: toCredentialGroupConnectionStatus(connection.status), + count: connection.count, } const current = mcpConnectionsByEnrollment.get(connection.enrollmentId) if (current) current.push(summary) @@ -1175,12 +1183,18 @@ async function buildPublicCredentialGroupEnrollment( ) ), ]) - const mcpConnectionByServerId = new Map( - mcpConnectionRows.map((connection) => { - if (!connection.mcpServerId) throw new Error('Managed MCP credential has no server') - return [connection.mcpServerId, connection] as const - }) - ) + const mcpConnectionByServerId = new Map() + for (const connection of mcpConnectionRows) { + if (!connection.mcpServerId) throw new Error('Managed MCP credential has no server') + mcpConnectionByServerId.set( + connection.mcpServerId, + moreUsableAccount( + mcpConnectionByServerId.get(connection.mcpServerId), + connection, + (account) => account.status + ) + ) + } const options = projection ? row.options.filter( (option) => option.id === projection.optionId && option.status === 'active' diff --git a/apps/sim/lib/credential-groups/limits.ts b/apps/sim/lib/credential-groups/limits.ts index 60963f14139..9d2e1c019ec 100644 --- a/apps/sim/lib/credential-groups/limits.ts +++ b/apps/sim/lib/credential-groups/limits.ts @@ -7,3 +7,5 @@ export const CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH = 255 /** Knowledge connectors one credential option may back at once. */ export const CREDENTIAL_GROUP_KNOWLEDGE_CONNECTOR_ACCESS_LIMIT = 32 export const ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT = 100 +/** Live accounts one member may connect per provider; bounds a search's per-account fan-out. */ +export const CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT = 5 diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts index 5a560bc65f5..8e74871bacf 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts @@ -1,3 +1,5 @@ +import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits' + export const MANAGED_MCP_CONNECTOR_IDS = [ 'fireflies', 'granola', @@ -102,6 +104,32 @@ export const MANAGED_MCP_CONNECTORS = { }, } as const satisfies Record +/** + * The vendor-documented tool naming the signed-in account. Only these connectors can tell two + * accounts apart, so every other connector stays at one connection per person. + */ +const MANAGED_MCP_ACCOUNT_IDENTITY_CALLS: Partial< + Record }> +> = { + notion: { name: 'notion-get-self', arguments: {} }, + granola: { name: 'get_account_info', arguments: {} }, + fireflies: { name: 'fireflies_get_user', arguments: {} }, + coda: { name: 'whoami', arguments: {} }, + hubspot: { name: 'get_user_details', arguments: { include: ['TOOL_INFORMATION'] } }, +} + +export function managedMcpAccountIdentityCall(connectorId: string | null | undefined) { + return connectorId && isManagedMcpConnectorId(connectorId) + ? MANAGED_MCP_ACCOUNT_IDENTITY_CALLS[connectorId] + : undefined +} + +export function managedMcpAccountLimit(connectorId: string | null | undefined): number { + return managedMcpAccountIdentityCall(connectorId) + ? CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT + : 1 +} + const DATABRICKS_WORKSPACE_HOST_SUFFIXES = [ '.cloud.databricks.com', '.cloud.databricks.us', diff --git a/apps/sim/lib/credential-groups/mcp-account-identity.test.ts b/apps/sim/lib/credential-groups/mcp-account-identity.test.ts new file mode 100644 index 00000000000..d6d8ffe733b --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-account-identity.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { readManagedMcpAccountIdentity } from '@/lib/credential-groups/mcp-account-identity' +import type { McpToolResult } from '@/lib/mcp/types' + +const text = (value: unknown): McpToolResult => ({ + content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }], +}) + +describe('reading the connected account from a provider response', () => { + it('uses the documented Notion user and workspace', () => { + expect( + readManagedMcpAccountIdentity( + 'notion', + text({ + workspace: { id: 'workspace-1', name: 'Engineering' }, + user: { type: 'person', id: 'user-1', name: 'Pat', email: 'pat@example.test' }, + }) + ) + ).toEqual({ + subject: 'user-1', + tenant: 'workspace-1', + label: 'pat@example.test · Engineering', + }) + }) + + it('falls back to the workspace name for a Notion bot with no email', () => { + expect( + readManagedMcpAccountIdentity( + 'notion', + text({ + workspace: { id: 'workspace-1', name: 'Engineering' }, + user: { type: 'bot', id: 'bot-1' }, + }) + ) + ).toEqual({ subject: 'bot-1', tenant: 'workspace-1', label: 'Engineering' }) + }) + + it('scopes HubSpot to its account even when no user field is returned', () => { + expect( + readManagedMcpAccountIdentity('hubspot', { + structuredContent: { accountId: 12345 }, + }) + ).toEqual({ subject: '12345', tenant: '12345', label: 'HubSpot account 12345' }) + }) + + it.each([ + ['granola', { email: 'Pat@Example.test', workspaces: [{ name: 'One' }] }], + ['fireflies', { user: { email: 'pat@example.test', name: 'Pat' } }], + ['coda', { toolName: 'whoami', result: { id: 7, email: 'pat@example.test' } }], + ])('uses the normalized email from %s JSON', (connector, payload) => { + expect(readManagedMcpAccountIdentity(connector, text(payload))).toEqual({ + subject: 'pat@example.test', + tenant: null, + label: 'pat@example.test', + }) + }) + + it('reads the one email in a non-JSON response', () => { + expect( + readManagedMcpAccountIdentity('fireflies', text('name: Pat\nemail: pat@example.test')) + ).toMatchObject({ subject: 'pat@example.test' }) + }) + + it.each([ + ['sentence punctuation', 'Signed in as pat@example.test.', 'pat@example.test'], + ['markdown', '**Email:** **pat@example.test**', 'pat@example.test'], + ['a punycode domain', 'email: pat@example.xn--p1ai', 'pat@example.xn--p1ai'], + ['a leading underscore', 'email: _pat@example.test', '_pat@example.test'], + ])('reads a labeled email in text despite %s', (_name, value, subject) => { + expect(readManagedMcpAccountIdentity('granola', text(value))).toMatchObject({ subject }) + }) + + it.each<[string, string, McpToolResult]>([ + [ + 'a tool error', + 'fireflies', + { isError: true, content: [{ type: 'text', text: 'pat@example.test' }] }, + ], + ['no content', 'fireflies', { content: [] }], + ['a response with no email', 'fireflies', text({ name: 'Pat' })], + ['two labeled emails', 'fireflies', text('email: a@example.test\nemail: b@example.test')], + ['an email nothing names as the account', 'fireflies', text('Owner: a@example.test')], + ['a Notion response without a user', 'notion', text({ workspace: { id: 'workspace-1' } })], + ['a Notion response without a workspace', 'notion', text({ user: { id: 'user-1' } })], + ])('reports no identity for %s rather than guessing', (_name, connector, result) => { + expect(readManagedMcpAccountIdentity(connector, result)).toBeNull() + }) + + it('reports no identity for a connector with no identity tool', () => { + expect(readManagedMcpAccountIdentity('lucid', text({ email: 'pat@example.test' }))).toBeNull() + }) +}) diff --git a/apps/sim/lib/credential-groups/mcp-account-identity.ts b/apps/sim/lib/credential-groups/mcp-account-identity.ts new file mode 100644 index 00000000000..80e1d907e06 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-account-identity.ts @@ -0,0 +1,103 @@ +import { isRecordLike, toRecord } from '@sim/utils/object' +import { isValidEmailSyntax } from '@sim/utils/string' +import type { McpToolResult } from '@/lib/mcp/types' + +export interface ManagedMcpAccountIdentity { + subject: string + tenant: string | null + label: string +} + +/** An address only counts in text when a label names it as the signed-in account. */ +const LABELED_EMAIL_IN_TEXT = + /(?:e-?mail|signed in as|logged in as)[\s*`"':=]*([a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z][a-z0-9-]+)/gi + +function responseText(result: McpToolResult) { + return (result.content ?? []) + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join('\n') +} + +function responsePayload(result: McpToolResult): unknown { + if (result.structuredContent !== undefined) return result.structuredContent + try { + return JSON.parse(responseText(result)) + } catch { + return undefined + } +} + +function stringOrNumber(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function emailFrom(result: McpToolResult, payload: unknown): string | null { + const queue: unknown[] = [payload] + for (let depth = 0; depth < 3; depth += 1) { + const value = queue[depth] + if (!isRecordLike(value)) continue + if (typeof value.email === 'string' && isValidEmailSyntax(value.email.trim())) + return value.email.trim().toLowerCase() + queue.push(value.user ?? value.result ?? value.data) + } + if (payload !== undefined) return null + const found = new Set( + [...responseText(result).matchAll(LABELED_EMAIL_IN_TEXT)] + .map((match) => (match[1] ?? '').toLowerCase()) + .filter((email) => isValidEmailSyntax(email)) + ) + return found.size === 1 ? ([...found][0] ?? null) : null +} + +/** + * Reads the signed-in account from the connector's identity tool. Response shapes come from each + * vendor's documentation, so anything unexpected yields `null`, never a guessed identity: an + * unidentified connection keeps working but cannot have another account added beside it. + */ +export function readManagedMcpAccountIdentity( + connectorId: string, + result: McpToolResult +): ManagedMcpAccountIdentity | null { + if (result.isError) return null + const payload = responsePayload(result) + switch (connectorId) { + case 'notion': { + const record = toRecord(payload) + const user = toRecord(record.user) + const workspace = toRecord(record.workspace) + const subject = stringOrNumber(user.id) + const tenant = stringOrNumber(workspace.id) + if (!subject || !tenant) return null + const person = stringOrNumber(user.email) ?? stringOrNumber(user.name) + const workspaceName = stringOrNumber(workspace.name) + return { + subject, + tenant, + label: + person && workspaceName + ? `${person} · ${workspaceName}` + : (person ?? workspaceName ?? subject), + } + } + case 'hubspot': { + const record = toRecord(payload) + const tenant = stringOrNumber(record.accountId) + if (!tenant) return null + return { + subject: stringOrNumber(record.userId) ?? tenant, + tenant, + label: `HubSpot account ${tenant}`, + } + } + case 'granola': + case 'fireflies': + case 'coda': { + const email = emailFrom(result, payload) + return email ? { subject: email, tenant: null, label: email } : null + } + default: + return null + } +} diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts index 33f2c3513fb..0c83fcc4f31 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -4,9 +4,13 @@ import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { assertCredentialGroupOAuthAttemptVersion } from '@/lib/credential-groups/oauth-attempt-version' +import { + type CredentialGroupConnectionIntent, + credentialGroupConnectionIntentSchema, +} from '@/lib/credential-groups/oauth-intent' const MCP_OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 -const MCP_OAUTH_ATTEMPT_VERSION = 3 as const +const MCP_OAUTH_ATTEMPT_VERSION = 4 as const const MCP_OAUTH_STATE_PREFIX = 'mcp_cg_' const CONSUME_SCRIPT = ` @@ -29,6 +33,7 @@ return #keys interface StoredCredentialGroupMcpOAuthAttempt { completionId?: string + connectionIntent?: CredentialGroupConnectionIntent returnTo?: 'integrations' oauthConfigVersion: number configurationFingerprint?: string @@ -47,6 +52,7 @@ interface StoredCredentialGroupMcpOAuthAttempt { export interface CredentialGroupMcpOAuthAttempt { completionId?: string + connectionIntent?: CredentialGroupConnectionIntent returnTo?: 'integrations' oauthConfigVersion: number configurationFingerprint?: string @@ -91,6 +97,8 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuth (candidate.completionId === undefined || (typeof candidate.completionId === 'string' && isValidUuid(candidate.completionId))) && (candidate.returnTo === undefined || candidate.returnTo === 'integrations') && + (candidate.connectionIntent === undefined || + credentialGroupConnectionIntentSchema.safeParse(candidate.connectionIntent).success) && typeof candidate.userId === 'string' && candidate.userId.length > 0 && ((typeof candidate.workspaceId === 'string' && @@ -117,6 +125,7 @@ export function isCredentialGroupMcpOAuthState(state: string): boolean { export async function createCredentialGroupMcpOAuthAttempt(params: { completionId?: string + connectionIntent?: CredentialGroupConnectionIntent returnTo?: 'integrations' oauthConfigVersion: number configurationFingerprint?: string @@ -145,6 +154,7 @@ export async function createCredentialGroupMcpOAuthAttempt(params: { const attempt: StoredCredentialGroupMcpOAuthAttempt = { version: MCP_OAUTH_ATTEMPT_VERSION, completionId: params.completionId, + connectionIntent: params.connectionIntent, returnTo: params.returnTo, oauthConfigVersion: params.oauthConfigVersion, configurationFingerprint: params.configurationFingerprint, @@ -189,6 +199,7 @@ export async function consumeCredentialGroupMcpOAuthAttempt( return { state, completionId: parsed.completionId, + connectionIntent: parsed.connectionIntent, returnTo: parsed.returnTo, oauthConfigVersion: parsed.oauthConfigVersion, configurationFingerprint: parsed.configurationFingerprint, diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts index 41e657d199f..701ebf26b97 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -1,10 +1,27 @@ import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import type { CredentialGroupMcpOAuthContext } from '@/lib/credential-groups/enrollments' +import { managedMcpAccountIdentityCall } from '@/lib/credential-groups/managed-mcp-connectors' +import { + type ManagedMcpAccountIdentity, + readManagedMcpAccountIdentity, +} from '@/lib/credential-groups/mcp-account-identity' import { createCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' -import { encryptManagedMcpTokens, persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' +import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' +import { + encryptManagedMcpTokens, + loadScopedManagedMcpRuntimeCredential, + persistManagedMcpCredential, + recordManagedMcpAccountIdentity, +} from '@/lib/credentials/managed-mcp' +import { createManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' import { assertSafeOauthServerUrl, getOrCreateOauthRow, @@ -17,10 +34,16 @@ import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' import { mcpService } from '@/lib/mcp/service' import { toManagedMcpToolSnapshots } from '@/lib/mcp/tool-snapshot' +const logger = createLogger('CredentialGroupMcpOAuth') + export async function startCredentialGroupMcpOAuth( context: CredentialGroupMcpOAuthContext, invitationToken: string, - completion: { completionId?: string; returnTo?: 'integrations' } = {} + completion: { + completionId?: string + returnTo?: 'integrations' + connectionIntent?: CredentialGroupConnectionIntent + } = {} ): Promise { assertSafeOauthServerUrl(context.server.url) return withMcpOauthRefreshLock(context.server.id, async () => { @@ -64,12 +87,67 @@ export async function startCredentialGroupMcpOAuth( }) } +/** + * Identifies this person's connections made before accounts were identified, through their stored + * tokens, so adding another account does not first require reconnecting them. A connection whose + * tokens no longer work stays unidentified, and the add is then refused with a reconnect prompt. + */ +export async function identifyStoredManagedMcpConnections({ + organizationId, + workspaceId, + enrollmentId, + server, +}: { + organizationId?: string + workspaceId?: string + enrollmentId: string + server: { id: string; connectorId: string } +}) { + const identityCall = managedMcpAccountIdentityCall(server.connectorId) + if (!identityCall) return + const scope = resourceScopeFromOwner({ organizationId, workspaceId }) + const unidentified = await db + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, enrollmentId), + eq(credential.mcpServerId, server.id), + isNull(credential.providerSubjectId), + isNull(credential.revokedAt) + ) + ) + for (const { id } of unidentified) { + try { + const runtime = await loadScopedManagedMcpRuntimeCredential(id, scope) + const identity = readManagedMcpAccountIdentity( + server.connectorId, + await mcpService.executeManagedMcpTool({ + connectionId: id, + serverId: server.id, + scope, + toolCall: identityCall, + loadAuthProvider: () => createManagedMcpAuthProvider(runtime), + }) + ) + if (identity) await recordManagedMcpAccountIdentity(id, identity) + } catch (error) { + logger.warn('Could not identify a stored managed MCP connection', { + connectorId: server.connectorId, + error: getErrorMessage(error), + }) + } + } +} + export async function completeCredentialGroupMcpOAuth( context: CredentialGroupMcpOAuthContext, codeVerifier: string, authorizationCode: string, invitationToken: string, - expectedConfigurationFingerprint?: string + expectedConfigurationFingerprint?: string, + connectionIntent?: CredentialGroupConnectionIntent ) { assertSafeOauthServerUrl(context.server.url) const clientRow = await getOrCreateOauthRow({ @@ -107,6 +185,33 @@ export async function completeCredentialGroupMcpOAuth( undefined, { requireComplete: true } ) + const identityCall = managedMcpAccountIdentityCall(context.server.connectorId) + let identity: ManagedMcpAccountIdentity | null = null + if (identityCall && tools.some((tool) => tool.name === identityCall.name)) { + try { + identity = readManagedMcpAccountIdentity( + context.server.connectorId, + await mcpService.callManagedMcpToolWithGrant( + context.server.id, + resourceScopeFromOwner(context), + provider, + identityCall + ) + ) + } catch (error) { + logger.warn('Could not identify the connected managed MCP account', { + connectorId: context.server.connectorId, + error: getErrorMessage(error), + }) + } + } + if (identity && connectionIntent?.kind === 'create') + await identifyStoredManagedMcpConnections({ + organizationId: context.organizationId, + workspaceId: context.workspaceId, + enrollmentId: context.enrollmentId, + server: context.server, + }) const completion = await persistManagedMcpCredential({ invitationTokenHash: sha256Hex(invitationToken), oauthConfigVersion: context.server.oauthConfigVersion, @@ -120,6 +225,8 @@ export async function completeCredentialGroupMcpOAuth( mcpServerName: context.server.name, tokens: grantedTokens, tools: toManagedMcpToolSnapshots(tools), + identity, + connectionIntent, }) return { ...completion, mcpServerId: context.server.id } } diff --git a/apps/sim/lib/credential-groups/member-integrations.test.ts b/apps/sim/lib/credential-groups/member-integrations.test.ts new file mode 100644 index 00000000000..00575a30c54 --- /dev/null +++ b/apps/sim/lib/credential-groups/member-integrations.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations' +import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' +import { memberIntegrationEntries } from '@/lib/credential-groups/member-integrations' + +function option(id: string, provider: string, requiredScopes: readonly string[] = []) { + return { + id, + provider, + label: provider, + required: false, + status: 'active', + configurationStatus: 'ready', + requiredScopes: [...requiredScopes], + } +} + +const account = (index: number, optionId: string, providerId: string) => ({ + credentialId: `credential-${optionId}-${index}`, + displayName: `account-${index}@example.test`, + optionId, + providerId, + status: 'active', +}) + +// double-cast-allowed: the fixture holds only the fields this projection reads +const settings = (viewerAccounts: unknown[], viewerMcpAccounts: unknown[] = []) => + ({ + credentialGroup: { + status: 'active', + options: [option('drive', 'google-drive')], + mcpServers: [ + { id: 'notion-server', managedConnectorId: 'notion', enabled: true }, + { id: 'lucid-server', managedConnectorId: 'lucid', enabled: true }, + ], + }, + assistantProviders: [], + availableProviders: ['google-drive'], + availableMcpConnectors: [], + viewerAccounts, + viewerMcpAccounts, + }) as unknown as OrganizationAccountsSettings + +const approved = (connectorType: string) => + ({ connectorType, approved: true, available: true }) as SearchIntegrationStatus + +function entryFor(provider: string, viewerAccounts: unknown[], viewerMcpAccounts: unknown[] = []) { + return memberIntegrationEntries(settings(viewerAccounts, viewerMcpAccounts), [ + approved('google_drive'), + approved('notion'), + approved('lucid'), + ]).find((entry) => entry.provider === provider) +} + +describe('offering another account', () => { + it.each(['jama', 'flow'] as const)( + 'keeps %s reconnects available but stops offering another account at the limit', + (provider) => { + const providerId = `${provider}-service-account` as const + const accounts = Array.from({ length: 5 }, (_, index) => ({ + credentialId: `${provider}-${index}`, + displayName: `account-${index}@example.test`, + providerId, + status: 'active' as const, + })) + const project = (viewerSavedAccounts: typeof accounts) => + memberIntegrationEntries({ ...settings([]), viewerSavedAccounts }, [ + approved(provider), + ]).find((entry) => entry.provider === provider) + + expect(project(accounts.slice(0, 4))?.connectionTarget).toEqual({ + savedProviderId: providerId, + }) + const full = project(accounts) + expect(full?.connectionTarget).toBeUndefined() + expect(full?.reconnectAccountIds).toEqual(accounts.map((entry) => entry.credentialId)) + } + ) + + it('still offers the provider to a member who has connected one account', () => { + expect(entryFor('google_drive', [account(1, 'drive', 'google-drive')])).toMatchObject({ + connectionTarget: { optionId: 'drive' }, + }) + }) + + it('stops offering it at the per-provider limit', () => { + const accounts = Array.from({ length: 5 }, (_, index) => + account(index, 'drive', 'google-drive') + ) + expect(entryFor('google_drive', accounts)?.connectionTarget).toBeUndefined() + }) + + const mcpAccount = (index: number, mcpServerId: string) => ({ + credentialId: `mcp-cg-${mcpServerId}-${index}`, + displayName: `account-${index}`, + mcpServerId, + status: 'active', + }) + + it('offers another managed MCP account when the connector can identify accounts', () => { + expect(entryFor('notion', [], [mcpAccount(1, 'notion-server')])).toMatchObject({ + connectionTarget: { mcpServerId: 'notion-server' }, + }) + }) + + it('stops offering a managed MCP account at the limit', () => { + const accounts = Array.from({ length: 5 }, (_, index) => mcpAccount(index, 'notion-server')) + expect(entryFor('notion', [], accounts)?.connectionTarget).toBeUndefined() + }) + + it('offers one account for a connector that cannot identify accounts', () => { + expect(entryFor('lucid', [])?.connectionTarget).toEqual({ mcpServerId: 'lucid-server' }) + expect(entryFor('lucid', [], [mcpAccount(1, 'lucid-server')])?.connectionTarget).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/credential-groups/member-integrations.ts b/apps/sim/lib/credential-groups/member-integrations.ts new file mode 100644 index 00000000000..84a793d1f96 --- /dev/null +++ b/apps/sim/lib/credential-groups/member-integrations.ts @@ -0,0 +1,275 @@ +import type { ComponentType } from 'react' +import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations' +import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' +import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers' +import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits' +import { managedMcpAccountLimit } from '@/lib/credential-groups/managed-mcp-connectors' +import { + findCredentialGroupProviderFromProviderId, + getCredentialGroupProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes' +import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' +import { + LIVE_SEARCH_SOURCE_TYPES, + liveSearchMcpConnector, + liveSearchMemberAccountProvider, + liveSearchSavedMemberProvider, +} from '@/lib/sim-search/live/source-catalog' + +const SEARCH_ACCOUNT_PROVIDERS = new Set([ + 'slack', + ...LIVE_SEARCH_SOURCE_TYPES.flatMap(([source]) => { + const provider = liveSearchMemberAccountProvider(source) + return provider ? [provider] : [] + }), +]) + +export function assistantAccountOptions(data: OrganizationAccountsSettings, search: string) { + const providers = data.assistantProviders.filter( + (provider) => !SEARCH_ACCOUNT_PROVIDERS.has(provider) + ) + const configured = + data.credentialGroup?.options.filter( + (option) => !SEARCH_ACCOUNT_PROVIDERS.has(option.provider) + ) ?? [] + const query = search.trim().toLowerCase() + const visible = configured.filter((option) => + [ + getCredentialGroupProviderService(option.provider).name, + ...(data.viewerAccounts ?? []) + .filter((account) => account.optionId === option.id) + .map((account) => account.displayName), + ] + .join(' ') + .toLowerCase() + .includes(query) + ) + return { providers, configured, visible } +} + +export interface MemberIntegration { + id: string + provider: string + name: string + icon: ComponentType<{ className?: string }> + accounts: MemberIntegrationAccount[] + permissionAccountIds: string[] + reconnectAccountIds: string[] + connectionTarget?: + | { optionId: string } + | { mcpServerId: string } + | { savedProviderId: 'jama-service-account' | 'flow-service-account' } + ready: boolean + managed?: boolean + description?: string +} + +type MemberIntegrationAccount = + | NonNullable[number] + | NonNullable[number] + | NonNullable[number] + +/** One member inventory, preserving native grant and managed MCP server identities. */ +export function memberIntegrationEntries( + data: OrganizationAccountsSettings, + policies: SearchIntegrationStatus[] | undefined +): MemberIntegration[] { + const group = data.credentialGroup + const approvals = new Map(policies?.map((policy) => [policy.connectorType, policy])) + const permissionIds = new Set( + (data.viewerAccounts ?? []) + .filter((account) => { + const provider = findCredentialGroupProviderFromProviderId(account.providerId) + return provider !== null && data.assistantProviders.includes(provider) + }) + .map((account) => account.credentialId) + ) + const mcpProviders = new Map( + group?.mcpServers.map((server) => [server.id, server.managedConnectorId]) + ) + const entries: MemberIntegration[] = [] + for (const [provider, meta] of LIVE_SEARCH_SOURCE_TYPES) { + const savedProviderId = liveSearchSavedMemberProvider(provider) + if (savedProviderId === 'jama-service-account' || savedProviderId === 'flow-service-account') { + const savedAccounts = (data.viewerSavedAccounts ?? []).filter( + (account) => account.providerId === savedProviderId + ) + const approval = approvals.get(provider) + if (!savedAccounts.length && !approval?.approved) continue + const ready = approval?.approved === true && approval.available !== false + entries.push({ + id: `source:${provider}`, + provider, + name: meta.name, + icon: meta.icon, + accounts: savedAccounts, + ready, + permissionAccountIds: [], + reconnectAccountIds: ready ? savedAccounts.map((account) => account.credentialId) : [], + connectionTarget: + ready && savedAccounts.length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT + ? { savedProviderId } + : undefined, + description: !policies + ? undefined + : approval?.available === false + ? 'Currently unavailable' + : ready + ? 'Search with your own account' + : 'Disabled by your organization', + }) + continue + } + const memberProvider = + provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) + const mcp = liveSearchMcpConnector(provider) + const accounts: MemberIntegrationAccount[] = mcp + ? (data.viewerMcpAccounts ?? []).filter( + (account) => mcpProviders.get(account.mcpServerId) === provider + ) + : (data.viewerAccounts ?? []).filter( + (account) => + findCredentialGroupProviderFromProviderId(account.providerId) === memberProvider + ) + const approval = approvals.get(provider) + const available = + (provider !== 'hubspot' && provider !== 'zoom') || + data.availableMcpConnectors.includes(provider) + const configured = + group?.options.some((option) => option.provider === memberProvider) || + group?.mcpServers.some((server) => server.managedConnectorId === provider) + if ( + !LIVE_SEARCH_SCOPE_FIELDS[provider] || + (!accounts.length && + !configured && + (approval?.available === false || !available || !approval?.approved)) + ) + continue + const options = + group?.options.filter( + (option) => + option.provider === memberProvider && + option.status === 'active' && + option.configurationStatus === 'ready' && + (provider !== 'slack' || + (option.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) + ) ?? [] + const servers = mcp + ? (group?.mcpServers.filter( + (server) => server.managedConnectorId === provider && server.enabled + ) ?? []) + : [] + const option = options.find( + (candidate) => + accounts.filter((account) => 'optionId' in account && account.optionId === candidate.id) + .length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT + ) + const server = servers.find( + (candidate) => + accounts.filter( + (account) => 'mcpServerId' in account && account.mcpServerId === candidate.id + ).length < managedMcpAccountLimit(candidate.managedConnectorId) + ) + const ready = + (mcp + ? available + : memberProvider !== null && data.availableProviders.includes(memberProvider)) && + approval?.available !== false && + group?.status === 'active' && + (options.length > 0 || servers.length > 0) && + approval?.approved === true && + available + const description = + provider === 'gitlab' + ? 'Access follows project permissions' + : !policies + ? undefined + : approval?.available === false + ? 'Currently unavailable' + : !approval?.approved + ? 'Disabled by your organization' + : group && group.status !== 'active' + ? 'Connections are paused by your organization' + : !ready + ? 'Not configured' + : approval.policy?.accessMode === 'service_account' + ? 'Selected resources you can access' + : 'All accessible content' + entries.push({ + id: `source:${provider}`, + provider, + name: meta.name, + icon: meta.icon, + accounts, + ready, + managed: provider === 'gitlab', + description, + permissionAccountIds: accounts + .filter((account) => permissionIds.has(account.credentialId)) + .map((account) => account.credentialId), + reconnectAccountIds: ready + ? accounts + .filter((account) => + 'optionId' in account + ? options.some((candidate) => candidate.id === account.optionId) + : 'mcpServerId' in account && + servers.some((candidate) => candidate.id === account.mcpServerId) + ) + .map((account) => account.credentialId) + : [], + connectionTarget: server + ? { mcpServerId: server.id } + : option + ? { optionId: option.id } + : undefined, + }) + } + const { providers, configured } = assistantAccountOptions(data, '') + for (const provider of new Set(configured.map((option) => option.provider))) { + const options = configured.filter((option) => option.provider === provider) + const accounts = (data.viewerAccounts ?? []).filter((account) => + options.some((option) => option.id === account.optionId) + ) + const source = assistantSearchSourceForProvider(getCredentialGroupProviderId(provider)) + const policy = source ? approvals.get(source) : undefined + const readyOptions = options.filter( + (option) => + group?.status === 'active' && + option.status === 'active' && + option.configurationStatus === 'ready' && + providers.includes(provider) && + data.availableProviders.includes(provider) && + (!source || (policy?.approved === true && policy.available !== false)) + ) + const target = readyOptions.find( + (option) => + accounts.filter((account) => account.optionId === option.id).length < + CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT + ) + const service = getCredentialGroupProviderService(provider) + entries.push({ + id: `app:${provider}`, + provider, + name: service.name, + icon: service.icon, + accounts, + ready: readyOptions.length > 0, + description: + source && !policies + ? undefined + : readyOptions.length + ? undefined + : 'Connections unavailable', + connectionTarget: target ? { optionId: target.id } : undefined, + permissionAccountIds: accounts + .filter((account) => permissionIds.has(account.credentialId)) + .map((account) => account.credentialId), + reconnectAccountIds: accounts + .filter((account) => readyOptions.some((option) => option.id === account.optionId)) + .map((account) => account.credentialId), + }) + } + return entries.sort((left, right) => left.name.localeCompare(right.name)) +} diff --git a/apps/sim/lib/credential-groups/oauth-completion.ts b/apps/sim/lib/credential-groups/oauth-completion.ts index 25785030fde..7fe1bf79bb9 100644 --- a/apps/sim/lib/credential-groups/oauth-completion.ts +++ b/apps/sim/lib/credential-groups/oauth-completion.ts @@ -9,6 +9,15 @@ export const CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES = { '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.', + account_already_connected: + 'This account is already connected. To add a different one, sign in to it with the provider first, then try again.', + account_limit_reached: + 'You have reached the account limit for this integration. Disconnect an account, then try again.', + account_identity_required: + 'Sim could not confirm which account an existing connection belongs to. Reconnect it, then try adding another account.', + account_unverified: 'Sim could not confirm which account this is. Try connecting it again.', + account_mismatch: + 'Reconnect with the account originally used for this connection. To use a different account, disconnect this one or add it from Integrations.', 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.', diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index 2c55dda9228..a008244d970 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -294,42 +294,6 @@ describe('credential group OAuth persistence', () => { } ) - it.each([ - { kind: 'create' as const }, - { kind: 'reconnect' as const, credentialId: 'other-account' }, - ])( - 'rejects changing an active account through the wrong connection intent', - async (connectionIntent) => { - dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) - queueTableRows(schemaMock.credentialGroup, [GROUP]) - queueTableRows(schemaMock.credential, [{ id: 'credential-1', revokedAt: null }]) - await expect( - completeCredentialGroupOAuth( - CONTEXT, - { - state: 'state', - userId: CONTEXT.credentialOwnerId, - provider: 'gmail', - nonceHash: 'nonce', - workspaceId: CONTEXT.workspaceId, - email: CONTEXT.email, - enrollmentId: CONTEXT.enrollmentId, - credentialGroupId: CONTEXT.credentialGroupId, - optionId: CONTEXT.option.id, - authorizationAppId: POLICY.authorizationAppId, - scopeVersion: POLICY.scopeVersion, - requiredScopes: POLICY.requiredScopes, - redirectUri: 'https://sim.test/callback', - invitationToken: 'token', - createdAt: Date.now(), - connectionIntent, - }, - 'code' - ) - ).rejects.toThrow('account changed') - expect(dbChainMockFns.update).not.toHaveBeenCalled() - } - ) it('preserves completed enrollment state when an account reconnects', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) queueTableRows(schemaMock.credentialGroup, [GROUP]) diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index a037e39d44d..9363c01e6a5 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -12,10 +12,12 @@ import { sameResourceScope, } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { resolveAccountWrite } from '@/lib/credential-groups/account-rules' import { type CredentialGroupOAuthContext, lockCredentialGroupEnrollmentLifecycle, } from '@/lib/credential-groups/enrollments' +import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits' import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' import { type CredentialGroupOAuthAttempt, @@ -222,10 +224,11 @@ async function persistGrant( ) } - const [existing] = await tx + const accounts = await tx .select({ id: credential.id, revokedAt: credential.revokedAt, + managedOauthStatus: credential.managedOauthStatus, providerSubjectId: credential.providerSubjectId, providerTenantId: credential.providerTenantId, encryptedOauthTokenSet: credential.encryptedOauthTokenSet, @@ -239,28 +242,12 @@ async function persistGrant( eq(credential.credentialGroupOptionId, context.option.id) ) ) - .limit(1) - - if ( - (connectionIntent?.kind === 'create' && existing && !existing.revokedAt) || - (connectionIntent?.kind === 'reconnect' && existing?.id !== connectionIntent.credentialId) - ) { - throw new CredentialGroupOAuthError( - 'The account changed during authorization. Refresh your connections and try again.', - 409 - ) - } - - if ( - existing && - (existing.providerSubjectId !== grant.providerSubjectId || - existing.providerTenantId !== grant.providerTenantId) - ) { - throw new CredentialGroupOAuthError( - 'Reconnect with the account originally used for this connection. Switching to a different account is not supported for this connection.', - 409 - ) - } + const existing = resolveAccountWrite({ + intent: connectionIntent, + limit: CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT, + accounts, + identity: { subject: grant.providerSubjectId, tenant: grant.providerTenantId }, + }) let refreshToken = grant.refreshToken if (!refreshToken && existing?.encryptedOauthTokenSet) { @@ -409,7 +396,13 @@ export async function completeCredentialGroupOAuth( sha256Hex(attempt.invitationToken), attempt.connectionIntent ) - if (attempt.connectionIntent && attempt.completionId && attempt.organizationId) { + /** Only Search cards and GitHub setup read a receipt; Integrations refreshes its inventory. */ + if ( + attempt.connectionIntent && + attempt.completionId && + attempt.organizationId && + attempt.returnTo !== 'integrations' + ) { await recordSearchConnectionCompletion({ organizationId: attempt.organizationId, userId: attempt.userId, diff --git a/apps/sim/lib/credential-groups/provider-adapter.ts b/apps/sim/lib/credential-groups/provider-adapter.ts index 897bcf9282b..09badad09a0 100644 --- a/apps/sim/lib/credential-groups/provider-adapter.ts +++ b/apps/sim/lib/credential-groups/provider-adapter.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import type { CredentialGroupOptionConfig, ManagedOAuthProviderMetadata } from '@sim/db/schema' import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import type { DbOrTx } from '@/lib/db/types' @@ -85,7 +86,8 @@ export class CredentialGroupOAuthError extends Error { constructor( message: string, readonly statusCode: 400 | 401 | 403 | 404 | 409 | 502 | 503, - readonly identityFailure?: OAuthIdentityVerificationError + readonly identityFailure?: OAuthIdentityVerificationError, + readonly failure?: CredentialGroupOAuthFailure ) { super(message) this.name = 'CredentialGroupOAuthError' diff --git a/apps/sim/lib/credential-groups/self-enrollment-oauth.ts b/apps/sim/lib/credential-groups/self-enrollment-oauth.ts index fe3265b7306..19b491a5148 100644 --- a/apps/sim/lib/credential-groups/self-enrollment-oauth.ts +++ b/apps/sim/lib/credential-groups/self-enrollment-oauth.ts @@ -52,6 +52,7 @@ export async function startViewerCredentialGroupMcpOAuth(input: { mcpServerId: string completionId: string returnTo?: 'integrations' + connectionIntent?: CredentialGroupConnectionIntent }): Promise<{ invitationLink: string; authorizationUrl: string }> { const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment(input) const token = new URL(invitationLink).pathname.split('/').at(-1) @@ -71,6 +72,7 @@ export async function startViewerCredentialGroupMcpOAuth(input: { const authorizationUrl = await startCredentialGroupMcpOAuth(oauth, token, { completionId: input.completionId, returnTo: input.returnTo, + connectionIntent: input.connectionIntent, }) return { invitationLink, authorizationUrl } } diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index accddd6511e..6698a99dcc6 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -94,6 +94,7 @@ export interface CredentialGroupEnrollmentMcpConnection { mcpServerId: string name: string status: 'active' | 'needs_reauth' | 'revoked' + count: number } export interface CredentialGroupEnrollmentDetail extends CredentialGroupEnrollmentRecord { diff --git a/apps/sim/lib/credential-groups/viewer-accounts.ts b/apps/sim/lib/credential-groups/viewer-accounts.ts index 1c9d1b45c93..3208145acde 100644 --- a/apps/sim/lib/credential-groups/viewer-accounts.ts +++ b/apps/sim/lib/credential-groups/viewer-accounts.ts @@ -8,7 +8,7 @@ import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limit export async function listViewerOrganizationAccounts(input: { organizationId: string userId: string - matching: SQL + matching?: SQL }) { const scope = { kind: 'organization', organizationId: input.organizationId } as const const accounts = await db diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts index 9cd6ba8a9b7..2b543104dbb 100644 --- a/apps/sim/lib/credentials/access.test.ts +++ b/apps/sim/lib/credentials/access.test.ts @@ -146,6 +146,23 @@ describe('organization background credential identity', () => { } ) + it.each(['jama-service-account', 'flow-service-account'])( + 'does not treat a personal %s credential as an organization-wide indexing identity', + async (providerId) => { + queueTableRows(credential, [ + { + workspaceId: null, + organizationId: 'org-1', + createdBy: 'admin-1', + type: 'service_account', + providerId, + }, + ]) + queueTableRows(member, [{ id: 'membership-1' }]) + await expect(resolveCredentialTokenIdentity('c1', scope)).resolves.toBeNull() + } + ) + it('never treats a raw account id as an organization credential', async () => { queueTableRows(credential, []) queueTableRows(account, [{ userId: 'admin-1' }]) diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 2a14fbbabb7..655f8277221 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -6,6 +6,7 @@ import { resourceScopeFromOwner, sameResourceScope, } from '@/lib/core/resource-scope' +import { isPersonalOrganizationServiceAccount } from '@/lib/credentials/service-account-provider-ids' import type { DbOrTx } from '@/lib/db/types' import { getUserEntityPermissions, @@ -84,6 +85,7 @@ export async function resolveCredentialTokenIdentity( createdBy: credential.createdBy, type: credential.type, accountId: credential.accountId, + providerId: credential.providerId, }) .from(credential) .where(eq(credential.id, credentialId)) @@ -93,6 +95,7 @@ export async function resolveCredentialTokenIdentity( if (!sameResourceScope(resourceScopeFromOwner(platformCredential), scope)) return null if (platformCredential.type === 'service_account') { if (scope.kind === 'organization') { + if (isPersonalOrganizationServiceAccount(platformCredential.providerId)) return null if (!platformCredential.createdBy) return null const [membership] = await db .select({ id: member.id }) diff --git a/apps/sim/lib/credentials/application/crud.test.ts b/apps/sim/lib/credentials/application/crud.test.ts index 1300e6f44e3..87c15d24087 100644 --- a/apps/sim/lib/credentials/application/crud.test.ts +++ b/apps/sim/lib/credentials/application/crud.test.ts @@ -32,6 +32,7 @@ const hoisted = vi.hoisted(() => ({ createRecord: vi.fn(), personalAccounts: vi.fn(), createPersonalToken: vi.fn(), + updatePersonalToken: vi.fn(), })) const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig @@ -54,7 +55,7 @@ vi.mock('@/lib/credentials/application/workspace-personal-accounts', () => ({ })) vi.mock('@/lib/credentials/personal-tokens', () => ({ createPersonalTokenCredential: hoisted.createPersonalToken, - updatePersonalTokenCredential: vi.fn(), + updatePersonalTokenCredential: hoisted.updatePersonalToken, })) vi.mock('@/lib/core/config/block-visibility', () => blockVisibilityMock) vi.mock('@/lib/integrations/principal-scope.server', () => ({ allowedIntegrationTypes: vi.fn() })) @@ -237,6 +238,40 @@ describe('updateWorkspaceCredentialUseCase', () => { ).rejects.toMatchObject({ detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' }) expect(mocks.updateRecord).not.toHaveBeenCalled() }) + + it('rejects provider workspace changes that a personal token cannot persist', async () => { + const personalToken = { + ...credential, + type: 'personal_token' as const, + providerId: 'gitlab', + workspaceId: null, + organizationId: 'organization-1', + } + mocks.loadWorkspace.mockResolvedValue({ + ...workspace, + workspaceOrganizationId: personalToken.organizationId, + }) + mocks.getWorkspaceCredential.mockResolvedValue(personalToken) + mocks.getActor.mockResolvedValue({ + credential: personalToken, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.updatePersonalToken.mockResolvedValue({ success: true, updatedFields: [] }) + + await expect( + updateWorkspaceCredentialUseCase.execute({ + principal: sessionPrincipal, + input: { + credentialId: personalToken.id, + assertedWorkspaceId: WORKSPACE_ID, + providerWorkspaceId: 'another-provider-workspace', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.updatePersonalToken).not.toHaveBeenCalled() + }) }) describe('personal-credential capability', () => { diff --git a/apps/sim/lib/credentials/application/organization-credentials.test.ts b/apps/sim/lib/credentials/application/organization-credentials.test.ts index 7655357c138..0491eb6e59f 100644 --- a/apps/sim/lib/credentials/application/organization-credentials.test.ts +++ b/apps/sim/lib/credentials/application/organization-credentials.test.ts @@ -16,7 +16,7 @@ import { organizationAuthorizationMock, organizationAuthorizationMockFns, } from '@sim/testing/mocks/organization-authorization.mock' -import { and, eq, isNull, or } from 'drizzle-orm' +import { and, eq, isNull, notInArray, or } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -69,6 +69,7 @@ import { organizationCredentialOperations, resolveOrganizationCredentialTokenBundle, saveOrganizationCredentialDraft, + updateOrganizationCredential, } from '@/lib/credentials/application/organization-credentials' const mocks = { @@ -147,7 +148,13 @@ describe('organization connection application boundary', () => { and( and(eq(credential.organizationId, 'org-1'), isNull(credential.workspaceId)), or( - eq(credential.type, 'service_account'), + and( + eq(credential.type, 'service_account'), + or( + eq(credential.createdBy, 'admin-1'), + notInArray(credential.providerId, ['jama-service-account', 'flow-service-account']) + ) + ), and(eq(credential.type, 'oauth'), eq(credential.createdBy, 'admin-1')) ), eq(credential.type, 'oauth'), @@ -274,6 +281,88 @@ describe('organization connection application boundary', () => { }) }) +describe('personal organization service-account isolation', () => { + beforeEach(() => { + resetDbChainMock() + mocks.authorize.mockResolvedValue({ organizationId: 'org-1', userId: 'admin-1', role: 'admin' }) + mocks.catalog.mockResolvedValue([]) + mocks.resolveToken.mockResolvedValue({ accessToken: 'private-token' }) + mocks.create.mockResolvedValue({ success: true, created: true, credential: row }) + mocks.update.mockResolvedValue({ success: true, updatedFields: ['displayName'] }) + }) + + it.each(['jama-service-account', 'flow-service-account'])( + 'does not let an admin use another member’s %s connection', + async (providerId) => { + mocks.getCredential.mockResolvedValue({ ...row, providerId, createdBy: 'other-member' }) + await expect( + resolveOrganizationCredentialTokenBundle({ + principal, + organizationId: 'org-1', + credentialId: row.id, + expectedProviderId: providerId, + requestId: 'test', + purpose: 'browsing', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + it.each(['jama-service-account', 'flow-service-account'])( + 'requires the personal connection flow when creating %s', + async (providerId) => { + await expect( + createOrganizationCredential.execute({ + principal, + input: { + organizationId: 'org-1', + type: 'service_account', + providerId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + ) + + it.each([ + { owner: 'other-member', code: 'not_found' }, + { owner: 'admin-1', code: 'validation' }, + ])( + 'keeps personal credential updates in the identity-verified flow for $owner', + async ({ owner, code }) => { + mocks.getCredential.mockResolvedValue({ + ...row, + providerId: 'jama-service-account', + createdBy: owner, + }) + await expect( + updateOrganizationCredential.execute({ + principal, + input: { + organizationId: 'org-1', + credentialId: row.id, + displayName: 'Renamed', + }, + }) + ).rejects.toMatchObject({ code }) + } + ) + + it('still lets the owner browse using their personal credential', async () => { + mocks.getCredential.mockResolvedValue({ ...row, providerId: 'jama-service-account' }) + await expect( + resolveOrganizationCredentialTokenBundle({ + principal, + organizationId: 'org-1', + credentialId: row.id, + expectedProviderId: 'jama-service-account', + requestId: 'test', + purpose: 'browsing', + }) + ).resolves.toMatchObject({ accessToken: 'private-token' }) + }) +}) + describe('organization member account browsing', () => { const managed = { ...row, diff --git a/apps/sim/lib/credentials/application/organization-credentials.ts b/apps/sim/lib/credentials/application/organization-credentials.ts index aadb9280dfe..b6e32aa51e8 100644 --- a/apps/sim/lib/credentials/application/organization-credentials.ts +++ b/apps/sim/lib/credentials/application/organization-credentials.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { account, credential } from '@sim/db/schema' -import { and, desc, eq, getTableColumns, or } from 'drizzle-orm' +import { and, desc, eq, getTableColumns, notInArray, or } from 'drizzle-orm' import type { CreateOrganizationCredentialBody, CreateOrganizationCredentialDraftBody, @@ -33,6 +33,10 @@ import { createCredentialRecord } from '@/lib/credentials/orchestration/credenti import { getOrganizationCredential } from '@/lib/credentials/organization' import { getOwnOrganizationManagedOAuthCredentials } from '@/lib/credentials/organization-managed' import type { CredentialRow } from '@/lib/credentials/queries' +import { + isPersonalOrganizationServiceAccount, + PERSONAL_ORGANIZATION_SERVICE_ACCOUNT_PROVIDER_IDS, +} from '@/lib/credentials/service-account-provider-ids' import { resolveCredentialTokenBundle, type ServiceAccountTokenResult, @@ -109,7 +113,15 @@ export const listOrganizationCredentials: OperationUseCase< organizationId: input.organizationId, }), or( - eq(credential.type, 'service_account'), + and( + eq(credential.type, 'service_account'), + or( + eq(credential.createdBy, context.userId), + notInArray(credential.providerId, [ + ...PERSONAL_ORGANIZATION_SERVICE_ACCOUNT_PROVIDER_IDS, + ]) + ) + ), and(eq(credential.type, 'oauth'), eq(credential.createdBy, context.userId)) ), ...(input.type ? [eq(credential.type, input.type)] : []), @@ -205,6 +217,15 @@ export const createOrganizationCredential: OperationUseCase< organizationCredentialOperations.create, input ) + if ( + input.type === 'service_account' && + isPersonalOrganizationServiceAccount(input.providerId) + ) { + throw new OrchestrationError( + 'validation', + 'Connect this account from your personal Search integrations' + ) + } const catalog = await listCredentialProviderCatalog(principal, input) if (input.type === 'service_account') requireAvailableServiceAccountCredentialProvider(catalog, input.providerId ?? '') @@ -332,7 +353,9 @@ export async function authorizeOrganizationCredentialUse(input: { row.type !== 'service_account' && !(row.type === 'managed_oauth' && input.purpose === 'browsing')) || !row.providerId || - (row.type === 'oauth' && row.createdBy !== context.userId) + ((row.type === 'oauth' || + (row.type === 'service_account' && isPersonalOrganizationServiceAccount(row.providerId))) && + row.createdBy !== context.userId) ) { throw new OrchestrationError('not_found', 'Credential not found') } @@ -406,9 +429,17 @@ export const updateOrganizationCredential: OperationUseCase< if ( !row || (row.type !== 'oauth' && row.type !== 'service_account') || - (row.type === 'oauth' && row.createdBy !== context.userId) + ((row.type === 'oauth' || + (row.type === 'service_account' && isPersonalOrganizationServiceAccount(row.providerId))) && + row.createdBy !== context.userId) ) throw new OrchestrationError('not_found', 'Credential not found') + if (row.type === 'service_account' && isPersonalOrganizationServiceAccount(row.providerId)) { + throw new OrchestrationError( + 'validation', + 'Update this account from your personal Search integrations' + ) + } const result = await withResourceOutboundScope(row, () => updateCredentialRecord({ ...input, credential: row }) ) diff --git a/apps/sim/lib/credentials/application/resolve-organization-personal-token.ts b/apps/sim/lib/credentials/application/resolve-organization-personal-token.ts index 3a8d8cd4c07..83cb929f2ba 100644 --- a/apps/sim/lib/credentials/application/resolve-organization-personal-token.ts +++ b/apps/sim/lib/credentials/application/resolve-organization-personal-token.ts @@ -22,6 +22,10 @@ import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/pers import { providerIdsForService } from '@/lib/oauth/utils' import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { + LIVE_SEARCH_SOURCE_TYPES, + liveSearchSavedMemberProvider, +} from '@/lib/sim-search/live/source-catalog' /** Search's organization delegation can use only the member's currently available personal grant. */ export const organizationPersonalCredentialOperation = defineOrganizationOperation({ @@ -156,6 +160,12 @@ export const prepareOrganizationPersonalConnection = { ...providerIdsForService(entry.providerId), ].some((name) => name.toLowerCase() === requested) ) + const saved = LIVE_SEARCH_SOURCE_TYPES.find(([type, meta]) => { + const providerId = liveSearchSavedMemberProvider(type) + return ( + providerId && [type, meta.name, providerId].some((name) => name.toLowerCase() === requested) + ) + }) const provider = connector ? findCredentialGroupProviderFromProviderId(connector.providerId) : CREDENTIAL_GROUP_PROVIDER_IDS.find((id) => @@ -163,11 +173,14 @@ export const prepareOrganizationPersonalConnection = { (name) => name.toLowerCase() === requested ) ) - if (!provider) + if (!provider && !saved) throw new OrchestrationError('validation', 'This integration is unavailable in Assistant.') const inventory = await listPersonalSearchIntegrations.execute({ principal, - input: { organizationId: context.organizationId, connectorType: connector?.type ?? provider }, + input: { + organizationId: context.organizationId, + connectorType: connector?.type ?? saved?.[0] ?? provider ?? undefined, + }, }) const target = input.credentialId ? inventory.connections @@ -176,8 +189,12 @@ export const prepareOrganizationPersonalConnection = { : inventory.available[0]?.target if (target) return { - provider: connector?.meta.name ?? getCredentialGroupProviderService(provider).name, - providerId: connector?.providerId ?? provider, + provider: + connector?.meta.name ?? + saved?.[1].name ?? + (provider ? getCredentialGroupProviderService(provider).name : input.providerName), + providerId: + connector?.providerId ?? (saved ? liveSearchSavedMemberProvider(saved[0]) : provider), target, } throw new OrchestrationError( diff --git a/apps/sim/lib/credentials/application/search-personal-credentials.integration.ts b/apps/sim/lib/credentials/application/search-personal-credentials.integration.ts new file mode 100644 index 00000000000..e0ba1baab8f --- /dev/null +++ b/apps/sim/lib/credentials/application/search-personal-credentials.integration.ts @@ -0,0 +1,378 @@ +import { db } from '@sim/db' +import { + credential, + credentialMember, + member, + organization, + organizationSearchIntegration, + user, +} from '@sim/db/schema' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { + credentialGroupsAvailabilityMock, + credentialGroupsAvailabilityMockFns, +} from '@sim/testing/mocks/credential-groups-availability.mock' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SaveSearchPersonalCredentialBody } from '@/lib/api/contracts/search-personal-credentials' +import { + removeSearchPersonalCredential, + saveSearchPersonalCredential, +} from '@/lib/credentials/application/search-personal-credentials' +import type { ServiceAccountSecretResult } from '@/lib/credentials/service-account-secret' + +const verifier = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/credential-groups/scoped-availability', () => credentialGroupsAvailabilityMock) +vi.mock('@/lib/credentials/service-account-secret', () => ({ + verifyAndBuildServiceAccountSecret: verifier, +})) + +/** Real transactions and authorization guard private provider credentials while verification is in flight. */ +describe('personal Search connections in PostgreSQL', () => { + const userId = generateId() + const adminId = generateId() + const organizationId = generateId() + const otherOrganizationId = generateId() + const email = `${userId}@personal-search.test` + const principal = createSessionPrincipal({ userId }) + const admin = createSessionPrincipal({ userId: adminId }) + const input: SaveSearchPersonalCredentialBody = { + organizationId, + providerId: 'jama-service-account', + orgId: 'https://requirements.example.test/jama', + clientId: 'fixture-client', + clientSecret: 'fixture-secret', + } + + function verified( + overrides: Partial = {} + ): ServiceAccountSecretResult { + return { + providerId: 'jama-service-account', + encryptedServiceAccountKey: 'fixture-encrypted-provider-secret', + displayName: email, + principal: { kind: 'user', id: '42', label: email }, + auditMetadata: {}, + ...overrides, + } + } + + const ownRows = () => + db.select().from(credential).where(eq(credential.organizationId, organizationId)) + const save = (overrides: Partial = {}) => + saveSearchPersonalCredential.execute({ principal, input: { ...input, ...overrides } }) + + beforeAll(async () => { + await db.insert(user).values( + [userId, adminId].map((id) => ({ + id, + name: 'Personal Search fixture', + email: id === userId ? email : `${id}@personal-search.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values( + [organizationId, otherOrganizationId].map((id) => ({ + id, + name: 'Personal Search fixture', + slug: id, + createdAt: new Date(), + })) + ) + }) + + beforeEach(async () => { + credentialGroupsAvailabilityMockFns.mockIsScopedCredentialGroupsAvailable.mockReset() + verifier.mockReset().mockResolvedValue(verified()) + await db + .delete(credential) + .where(inArray(credential.organizationId, [organizationId, otherOrganizationId])) + await db + .delete(member) + .where(inArray(member.organizationId, [organizationId, otherOrganizationId])) + await db.insert(member).values([ + { id: generateId(), organizationId, userId, role: 'member', createdAt: new Date() }, + { id: generateId(), organizationId, userId: adminId, role: 'admin', createdAt: new Date() }, + ]) + await db.update(user).set({ email, emailVerified: true }).where(eq(user.id, userId)) + await db + .insert(organizationSearchIntegration) + .values( + [organizationId, otherOrganizationId].flatMap((id) => + ['jama', 'flow'].map((connectorType) => ({ + organizationId: id, + connectorType, + approved: true, + })) + ) + ) + .onConflictDoUpdate({ + target: [ + organizationSearchIntegration.organizationId, + organizationSearchIntegration.connectorType, + ], + set: { approved: true }, + }) + }) + + afterAll(async () => { + await db + .delete(organization) + .where(inArray(organization.id, [organizationId, otherOrganizationId])) + await db.delete(user).where(inArray(user.id, [userId, adminId])) + }) + + it('refuses provider verification and persistence when connected accounts are unavailable', async () => { + credentialGroupsAvailabilityMockFns.mockIsScopedCredentialGroupsAvailable.mockResolvedValueOnce( + false + ) + await expect(save()).rejects.toMatchObject({ code: 'not_found' }) + expect(verifier).not.toHaveBeenCalled() + expect(await ownRows()).toEqual([]) + }) + + it('still removes a personal connection after connected accounts become unavailable', async () => { + const created = await save() + credentialGroupsAvailabilityMockFns.mockIsScopedCredentialGroupsAvailable.mockResolvedValue( + false + ) + await removeSearchPersonalCredential.execute({ + principal, + input: { organizationId, ...created }, + }) + expect(await ownRows()).toEqual([]) + }) + + it('creates a private connection, rotates its secret, and removes its membership with it', async () => { + const created = await save() + expect(await ownRows()).toMatchObject([ + { + id: created.credentialId, + createdBy: userId, + workspaceId: null, + providerSubjectId: '42', + providerTenantId: input.orgId, + }, + ]) + expect( + await db + .select() + .from(credentialMember) + .where(eq(credentialMember.credentialId, created.credentialId)) + ).toMatchObject([{ userId, role: 'admin', status: 'active' }]) + verifier.mockResolvedValue(verified({ encryptedServiceAccountKey: 'rotated-fixture-secret' })) + await save({ credentialId: created.credentialId }) + expect(await ownRows()).toMatchObject([ + { encryptedServiceAccountKey: 'rotated-fixture-secret' }, + ]) + await removeSearchPersonalCredential.execute({ + principal, + input: { organizationId, ...created }, + }) + expect(await ownRows()).toEqual([]) + expect( + await db + .select() + .from(credentialMember) + .where(eq(credentialMember.credentialId, created.credentialId)) + ).toEqual([]) + }) + + it.each(['different_email', 'unverified_email', 'non_user'] as const)( + 'does not persist a credential with %s identity', + async (mode) => { + if (mode === 'unverified_email') { + await db.update(user).set({ emailVerified: false }).where(eq(user.id, userId)) + } else { + verifier.mockResolvedValue( + verified({ + principal: + mode === 'non_user' + ? null + : { kind: 'user', id: '42', label: 'someone-else@personal-search.test' }, + }) + ) + } + await expect(save()).rejects.toMatchObject({ code: 'validation' }) + expect(await ownRows()).toEqual([]) + } + ) + + it.each(['membership', 'approval', 'email'] as const)( + 'rechecks %s changed while the provider verified the credential', + async (changed) => { + const started = createDeferred() + const resume = createDeferred() + verifier.mockImplementationOnce(() => { + started.resolve() + return resume.promise + }) + const pending = save() + const outcome = pending.catch((error: unknown) => error) + await started.promise + if (changed === 'membership') { + await db + .delete(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + } else if (changed === 'approval') { + await db + .update(organizationSearchIntegration) + .set({ approved: false }) + .where( + and( + eq(organizationSearchIntegration.organizationId, organizationId), + eq(organizationSearchIntegration.connectorType, 'jama') + ) + ) + } else { + await db + .update(user) + .set({ email: `changed-${email}` }) + .where(eq(user.id, userId)) + } + resume.resolve(verified()) + expect(await outcome).toMatchObject({ + code: + changed === 'membership' + ? 'not_found' + : changed === 'approval' + ? 'forbidden' + : 'validation', + }) + expect(await ownRows()).toEqual([]) + } + ) + + it.each(['tenant', 'subject'] as const)( + 'refuses to replace the %s on a reconnect', + async (changed) => { + const created = await save() + if (changed === 'subject') { + verifier.mockResolvedValue( + verified({ principal: { kind: 'user', id: '43', label: email } }) + ) + } + await expect( + save({ + ...created, + ...(changed === 'tenant' ? { orgId: 'https://other.example.test/jama' } : {}), + }) + ).rejects.toMatchObject({ failure: 'account_mismatch', statusCode: 409 }) + expect(await ownRows()).toMatchObject([ + { providerSubjectId: '42', providerTenantId: input.orgId }, + ]) + } + ) + + it('binds a Flow connection to its origin and workspace when reconnecting', async () => { + verifier.mockResolvedValue(verified({ providerId: 'flow-service-account' })) + const flow: Partial = { + providerId: 'flow-service-account', + domain: 'https://flow.example.test', + providerWorkspaceId: 'workspace-a', + apiToken: 'fixture-token', + } + const created = await save(flow) + await expect( + save({ ...flow, ...created, providerWorkspaceId: 'workspace-b' }) + ).rejects.toMatchObject({ failure: 'account_mismatch', statusCode: 409 }) + await expect( + save({ ...flow, ...created, domain: 'https://other-flow.example.test' }) + ).rejects.toMatchObject({ failure: 'account_mismatch', statusCode: 409 }) + expect(await ownRows()).toMatchObject([ + { providerTenantId: '["https://flow.example.test","workspace-a"]' }, + ]) + }) + + it('conceals a personal credential from other organization admins and from another tenant', async () => { + const created = await save() + await expect( + saveSearchPersonalCredential.execute({ principal: admin, input: { ...input, ...created } }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + removeSearchPersonalCredential.execute({ + principal: admin, + input: { organizationId, ...created }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(member) + .set({ organizationId: otherOrganizationId }) + .where(eq(member.userId, userId)) + await expect(save({ ...created, organizationId: otherOrganizationId })).rejects.toMatchObject({ + code: 'not_found', + }) + await expect( + removeSearchPersonalCredential.execute({ + principal, + input: { organizationId: otherOrganizationId, ...created }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(await ownRows()).toHaveLength(1) + }) + + it('serializes concurrent connections at the per-member account limit', async () => { + await db.insert(credential).values( + Array.from({ length: 99 }, () => ({ + id: generateId(), + organizationId, + createdBy: userId, + providerId: 'coda-service-account', + displayName: 'Limit fixture', + type: 'service_account' as const, + })) + ) + verifier.mockResolvedValueOnce( + verified({ principal: { kind: 'user', id: '43', label: email } }) + ) + const results = await Promise.allSettled([save(), save()]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toMatchObject([ + { reason: { code: 'validation' } }, + ]) + expect(await ownRows()).toHaveLength(100) + }) + + it('allows only one concurrent connection for the same verified provider identity', async () => { + const results = await Promise.allSettled([save(), save()]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toMatchObject([ + { reason: { failure: 'account_already_connected', statusCode: 409 } }, + ]) + expect(await ownRows()).toHaveLength(1) + }) + + it('limits concurrent additions per provider while allowing reconnect at the limit', async () => { + const ids: string[] = [] + for (let index = 0; index < 4; index++) { + verifier.mockResolvedValueOnce( + verified({ principal: { kind: 'user', id: `member-${index}`, label: email } }) + ) + ids.push((await save()).credentialId) + } + verifier.mockResolvedValueOnce( + verified({ principal: { kind: 'user', id: '43', label: email } }) + ) + const results = await Promise.allSettled([save(), save()]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toMatchObject([ + { reason: { failure: 'account_limit_reached', statusCode: 409 } }, + ]) + verifier.mockResolvedValueOnce( + verified({ + principal: { kind: 'user', id: 'member-0', label: email }, + encryptedServiceAccountKey: 'rotated-at-limit', + }) + ) + await save({ credentialId: ids[0] }) + const rows = await ownRows() + expect(rows).toHaveLength(5) + expect(rows.find((row) => row.id === ids[0])?.encryptedServiceAccountKey).toBe( + 'rotated-at-limit' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/search-personal-credentials.ts b/apps/sim/lib/credentials/application/search-personal-credentials.ts new file mode 100644 index 00000000000..7585c806242 --- /dev/null +++ b/apps/sim/lib/credentials/application/search-personal-credentials.ts @@ -0,0 +1,318 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, count, eq, inArray, isNull } from 'drizzle-orm' +import type { SaveSearchPersonalCredentialBody } from '@/lib/api/contracts/search-personal-credentials' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveAccountWrite } from '@/lib/credential-groups/account-rules' +import { + CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT, + ORGANIZATION_VIEWER_ACCOUNT_LIMIT, +} from '@/lib/credential-groups/limits' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { CredentialProviderOperationError } from '@/lib/credentials/application/crud' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { verifyAndBuildServiceAccountSecret } from '@/lib/credentials/service-account-secret' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { normalizeFlowOrigin, normalizeFlowWorkspaceId } from '@/lib/flow/client' +import { normalizeJamaInstanceUrl } from '@/lib/jama/client' +import { + listOrganizationSearchApprovals, + lockOrganizationSearchApproval, + requireOrganizationSearchApproval, +} from '@/lib/knowledge/search/integration-policy' +import { SAVED_MEMBER_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' + +const searchPersonalCredentialOperations = { + save: defineOrganizationOperation({ + id: 'search.personal_credentials.save', + minimumRole: 'member', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + remove: defineOrganizationOperation({ + id: 'search.personal_credentials.remove', + minimumRole: 'member', + principalKinds: ['session'], + capability: 'integrations.manage', + }), +} as const + +function ownedCredential(organizationId: string, userId: string, credentialId: string) { + return and( + eq(credential.id, credentialId), + eq(credential.organizationId, organizationId), + isNull(credential.workspaceId), + eq(credential.createdBy, userId), + eq(credential.type, 'service_account') + ) +} + +/** Personal Search accepts only the connecting member's freshly verified provider identity. */ +export const saveSearchPersonalCredential: OperationUseCase< + typeof searchPersonalCredentialOperations.save, + SaveSearchPersonalCredentialBody, + { credentialId: string } +> = { + operation: searchPersonalCredentialOperations.save, + async execute({ principal, input, request }) { + const context = await authorizeOrganizationOperation(principal, this.operation, input) + if ( + !(await isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId: context.organizationId, + })) + ) { + throw new OrchestrationError('not_found', 'Connected accounts are not available') + } + const provider = input.providerId === 'jama-service-account' ? 'jama' : 'flow' + await requireOrganizationSearchApproval(context.organizationId, provider) + if ( + !(await isSearchProviderEnabled(provider, { + kind: 'organization', + organizationId: context.organizationId, + })) + ) { + throw new OrchestrationError('forbidden', 'This Search integration is unavailable') + } + requireAvailableServiceAccountCredentialProvider( + await listCredentialProviderCatalog(principal, context), + input.providerId + ) + if (input.credentialId) { + const [existing] = await db + .select({ providerId: credential.providerId }) + .from(credential) + .where(ownedCredential(context.organizationId, context.userId, input.credentialId)) + .limit(1) + if (existing?.providerId !== input.providerId) { + throw new OrchestrationError('not_found', 'Personal connection not found') + } + } + let verified + try { + verified = await withResourceOutboundScope(context, () => + verifyAndBuildServiceAccountSecret(input.providerId, input) + ) + } catch (error) { + if (!(error instanceof TokenServiceAccountValidationError)) throw error + throw new CredentialProviderOperationError( + 'Could not verify your provider credentials. Check the connection details and try again.', + error.code, + error.code === 'provider_unavailable' + ) + } + const tenantId = + input.providerId === 'jama-service-account' + ? normalizeJamaInstanceUrl(input.orgId ?? '') + : JSON.stringify([ + normalizeFlowOrigin(input.domain), + normalizeFlowWorkspaceId(input.providerWorkspaceId ?? ''), + ]) + const providerEmail = + verified.principal?.kind === 'user' + ? verified.principal.label?.trim().toLowerCase() + : undefined + const result = await db.transaction(async (tx) => { + await lockOrganizationSearchApproval(tx, context.organizationId) + await acquireOrganizationUserMutationLocks(tx, { + userId: context.userId, + organizationIds: [context.organizationId], + }) + await authorizeOrganizationOperation(principal, this.operation, input, { + executor: tx, + forShare: true, + }) + if ( + (await listOrganizationSearchApprovals(context.organizationId, tx)).get(provider) !== true + ) { + throw new OrchestrationError('forbidden', 'This Search integration is no longer approved') + } + const [person] = await tx + .select({ email: user.email, verified: user.emailVerified }) + .from(user) + .where(eq(user.id, context.userId)) + .for('share') + .limit(1) + if ( + !person?.verified || + verified.principal?.kind !== 'user' || + !providerEmail || + providerEmail !== person.email.trim().toLowerCase() + ) { + throw new OrchestrationError( + 'validation', + 'Use your own provider account with the same email as your verified Sim account' + ) + } + const accounts = await tx + .select({ + id: credential.id, + revokedAt: credential.revokedAt, + providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, + }) + .from(credential) + .where( + and( + eq(credential.organizationId, context.organizationId), + isNull(credential.workspaceId), + eq(credential.createdBy, context.userId), + eq(credential.type, 'service_account'), + eq(credential.providerId, input.providerId) + ) + ) + .for('update') + const existing = resolveAccountWrite({ + intent: input.credentialId + ? { kind: 'reconnect', credentialId: input.credentialId } + : { kind: 'create' }, + limit: CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT, + accounts: accounts.map((account) => ({ ...account, managedOauthStatus: 'active' })), + identity: { subject: verified.principal.id, tenant: tenantId }, + }) + const now = new Date() + const credentialId = existing?.id ?? generateId() + const fields = { + displayName: input.displayName?.trim() || verified.displayName, + description: input.description?.trim() || null, + encryptedServiceAccountKey: verified.encryptedServiceAccountKey, + providerSubjectId: verified.principal.id, + providerTenantId: tenantId, + revokedAt: null, + updatedAt: now, + } + if (!existing || existing.revokedAt) { + const [saved] = await tx + .select({ count: count() }) + .from(credential) + .where( + and( + eq(credential.organizationId, context.organizationId), + isNull(credential.workspaceId), + eq(credential.createdBy, context.userId), + eq(credential.type, 'service_account'), + inArray(credential.providerId, SAVED_MEMBER_SEARCH_PROVIDER_IDS), + isNull(credential.revokedAt) + ) + ) + if (saved.count >= ORGANIZATION_VIEWER_ACCOUNT_LIMIT) { + throw new OrchestrationError( + 'validation', + 'Remove an unused personal Search connection before adding another' + ) + } + } + if (existing) { + const updated = await tx + .update(credential) + .set(fields) + .where( + and( + ownedCredential(context.organizationId, context.userId, credentialId), + eq(credential.providerId, input.providerId) + ) + ) + .returning({ id: credential.id }) + if (!updated.length) + throw new OrchestrationError('not_found', 'Personal connection not found') + } else { + await tx.insert(credential).values({ + id: credentialId, + organizationId: context.organizationId, + type: 'service_account', + providerId: input.providerId, + createdBy: context.userId, + ...fields, + }) + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId: context.userId, + role: 'admin', + status: 'active', + joinedAt: now, + }) + } + return { credentialId, created: !existing } + }) + recordProjectedUseCaseAuditEntries( + this.operation, + null, + principal, + request, + [ + { + action: result.created ? AuditAction.CREDENTIAL_CREATED : AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credentialId, + metadata: { providerId: input.providerId, ...verified.auditMetadata }, + }, + ], + context.organizationId + ) + return { credentialId: result.credentialId } + }, +} + +export const removeSearchPersonalCredential: OperationUseCase< + typeof searchPersonalCredentialOperations.remove, + { organizationId: string; credentialId: string }, + { success: true } +> = { + operation: searchPersonalCredentialOperations.remove, + async execute({ principal, input, request }) { + const context = await authorizeOrganizationOperation(principal, this.operation, input) + await db.transaction(async (tx) => { + await acquireOrganizationUserMutationLocks(tx, { + userId: context.userId, + organizationIds: [context.organizationId], + }) + await authorizeOrganizationOperation(principal, this.operation, input, { + executor: tx, + forShare: true, + }) + const [row] = await tx + .select({ providerId: credential.providerId }) + .from(credential) + .where(ownedCredential(context.organizationId, context.userId, input.credentialId)) + .for('update') + .limit(1) + if ( + row?.providerId !== 'jama-service-account' && + row?.providerId !== 'flow-service-account' + ) { + throw new OrchestrationError('not_found', 'Personal connection not found') + } + await tx + .delete(credential) + .where(ownedCredential(context.organizationId, context.userId, input.credentialId)) + }) + recordProjectedUseCaseAuditEntries( + this.operation, + null, + principal, + request, + [ + { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: input.credentialId, + }, + ], + context.organizationId + ) + return { success: true } + }, +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index e089c3edbaa..e57ce214e28 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -123,6 +123,7 @@ export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' a export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-account' as const +export const JAMA_SERVICE_ACCOUNT_PROVIDER_ID = 'jama-service-account' as const export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ @@ -148,6 +149,7 @@ export type ClientCredentialAccountProviderId = | typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID | typeof GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID | typeof HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID + | typeof JAMA_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -387,6 +389,34 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountDescriptor > = { + [JAMA_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: JAMA_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Jama Connect', + connectNoun: 'API credential', + fields: [ + { + id: 'orgId', + label: 'Jama URL', + placeholder: 'https://your-company.jamacloud.com', + secret: false, + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Enter your Jama client ID', + secret: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Enter your Jama client secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/jama', + helpText: + 'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.', + }, [RAMP_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: RAMP_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Ramp', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts new file mode 100644 index 00000000000..66592bf3efa --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts @@ -0,0 +1,33 @@ +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { describe, expect, it, vi } from 'vitest' +import { mintJamaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/jama' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +describe('Jama credential validation', () => { + it.each([ + ['ENOTFOUND', 'site_not_found', 400], + ['EAI_AGAIN', 'provider_unavailable', 502], + ['ETIMEDOUT', 'provider_unavailable', 502], + ])( + 'classifies %s without treating transient failures as a wrong instance', + async (code, expectedCode, status) => { + inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValueOnce( + new Error('url hostname could not be resolved', { + cause: Object.assign(new Error('DNS lookup failed'), { code }), + }) + ) + + await expect( + mintJamaServiceAccountToken({ + orgId: 'https://example.jamacloud.com', + clientId: 'client-id', + clientSecret: 'client-secret', + }) + ).rejects.toMatchObject({ code: expectedCode, status }) + } + ) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts new file mode 100644 index 00000000000..22038795a26 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts @@ -0,0 +1,112 @@ +import { toRecord } from '@sim/utils/object' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { userPrincipal } from '@/lib/credentials/principal' +import { + isTransientProviderStatus, + requireClientSecret, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { normalizeJamaInstanceUrl } from '@/lib/jama/client' + +/** Jama issues expiring tokens for the user who created the API credentials. */ +export async function mintJamaServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + let instanceUrl: string + try { + instanceUrl = normalizeJamaInstanceUrl(fields.orgId) + } catch { + throw new TokenServiceAccountValidationError('site_not_found', 400) + } + const secret = requireClientSecret(fields.clientSecret, 'jama_token', 'Jama Connect') + if (!fields.clientId || fields.clientId.includes(':') || /[\r\n]/.test(fields.clientId)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400) + } + async function request(path: string, headers: Record, body?: string) { + try { + const response = await secureFetchWithValidation(`${instanceUrl}/rest/${path}`, { + profile: 'configuredEndpoint', + method: body === undefined ? 'GET' : 'POST', + headers: { Accept: 'application/json', ...headers }, + body, + signal: options?.signal, + timeout: 10_000, + maxResponseBytes: 64 * 1024, + maxRedirects: 0, + logUrlValidationDetails: false, + }) + if (!response.ok) { + await response.body?.cancel() + throw new TokenServiceAccountValidationError( + response.status >= 400 && + response.status < 500 && + !isTransientProviderStatus(response.status) + ? 'invalid_credentials' + : 'provider_unavailable', + response.status + ) + } + return toRecord(await response.json()) + } catch (error) { + options?.signal?.throwIfAborted() + if (error instanceof TokenServiceAccountValidationError) throw error + if (toRecord(toRecord(error).cause).code === 'ENOTFOUND') { + throw new TokenServiceAccountValidationError('site_not_found', 400) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502) + } + } + const token = await request( + 'oauth/token', + { + Authorization: `Basic ${Buffer.from(`${fields.clientId}:${secret}`, 'utf8').toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + new URLSearchParams({ grant_type: 'client_credentials' }).toString() + ) + if ( + typeof token.access_token !== 'string' || + !token.access_token.trim() || + typeof token.expires_in !== 'number' || + !Number.isSafeInteger(token.expires_in) || + token.expires_in <= 0 + ) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502) + } + const result = { + accessToken: token.access_token, + expiresInSeconds: token.expires_in, + instanceUrl, + } + if (options?.skipIdentity) return result + const person = toRecord( + ( + await request('v1/users/current', { + Authorization: `Bearer ${result.accessToken}`, + }) + ).data + ) + if ( + typeof person.id !== 'number' || + !Number.isSafeInteger(person.id) || + person.id <= 0 || + person.active !== true + ) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400) + } + const email = typeof person.email === 'string' ? person.email.trim() : undefined + return { + ...result, + identity: { + displayName: email || `Jama user ${person.id}`, + principal: userPrincipal(String(person.id), email), + auditMetadata: { instanceUrl }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index b52147dae35..c4bba3af9d1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -7,6 +7,7 @@ import { getClientCredentialAccountDescriptor, HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID, isClientCredentialAccountProviderId, + JAMA_SERVICE_ACCOUNT_PROVIDER_ID, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID, ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, @@ -21,6 +22,7 @@ import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential- import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran' import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse' import { mintHelpScoutServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/helpscout' +import { mintJamaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/jama' import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite' import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm' import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' @@ -142,6 +144,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountMinter > = { + [JAMA_SERVICE_ACCOUNT_PROVIDER_ID]: mintJamaServiceAccountToken, [ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: mintZoomServiceAccountToken, [BOX_SERVICE_ACCOUNT_PROVIDER_ID]: mintBoxServiceAccountToken, [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken, diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index fed529c1ff6..55cbc87f633 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -20,12 +20,16 @@ import { sameResourceScopeCondition, } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { resolveAccountWrite } from '@/lib/credential-groups/account-rules' import type { OrganizationCredentialType } from '@/lib/credential-groups/credential-types' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnector, + managedMcpAccountLimit, requireManagedMcpConnectorUrl, } from '@/lib/credential-groups/managed-mcp-connectors' +import type { ManagedMcpAccountIdentity } from '@/lib/credential-groups/mcp-account-identity' +import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { loadPreregisteredClient } from '@/lib/mcp/oauth/provider' import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' @@ -324,9 +328,12 @@ export async function persistManagedMcpCredential(params: { mcpServerName: string tokens: OAuthTokens tools: ManagedMcpToolSnapshot[] + identity?: ManagedMcpAccountIdentity | null + connectionIntent?: CredentialGroupConnectionIntent }): Promise<{ connectionId: string created: boolean + displayName: string enrollmentStatus: 'in_progress' | 'completed' }> { const scope = resourceScopeFromOwner(params) @@ -387,8 +394,15 @@ export async function persistManagedMcpCredential(params: { if (source.managedConnectorId === 'zoom' && !(await isSearchProviderEnabled('zoom', scope))) throw new ManagedMcpCredentialError('Zoom Search is not available for this organization', 403) - const [existing] = await tx - .select({ id: credential.id }) + const accounts = await tx + .select({ + id: credential.id, + displayName: credential.displayName, + revokedAt: credential.revokedAt, + managedOauthStatus: credential.managedOauthStatus, + providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, + }) .from(credential) .where( and( @@ -397,11 +411,21 @@ export async function persistManagedMcpCredential(params: { eq(credential.mcpServerId, params.mcpServerId) ) ) - .limit(1) .for('update') + const identity = params.identity ?? null + const existing = resolveAccountWrite({ + intent: params.connectionIntent, + limit: managedMcpAccountLimit(source.managedConnectorId), + accounts, + identity, + }) + const displayName = identity?.label ?? existing?.displayName ?? params.mcpServerName const values = { mcpOauthConfigVersion: params.oauthConfigVersion, - displayName: params.mcpServerName, + displayName, + ...(identity + ? { providerSubjectId: identity.subject, providerTenantId: identity.tenant } + : {}), managedOauthStatus: 'active' as const, encryptedOauthTokenSet, accessTokenExpiresAt, @@ -459,11 +483,34 @@ export async function persistManagedMcpCredential(params: { return { connectionId, created: !existing, + displayName, enrollmentStatus: source.enrollmentStatus === 'completed' ? 'completed' : 'in_progress', } }) } +/** Names a connection made before accounts were identified, unless it was identified meanwhile. */ +export async function recordManagedMcpAccountIdentity( + credentialId: string, + identity: ManagedMcpAccountIdentity +) { + await db + .update(credential) + .set({ + providerSubjectId: identity.subject, + providerTenantId: identity.tenant, + displayName: identity.label, + updatedAt: new Date(), + }) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + isNull(credential.providerSubjectId) + ) + ) +} + export async function saveManagedMcpRuntimeTokens( credentialId: string, tokens: OAuthTokens | null, diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 0c17ba592d7..41a1af00008 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -77,6 +77,7 @@ export interface PerformCreateCredentialParams { serviceAccountJson?: string apiToken?: string domain?: string + providerWorkspaceId?: string atlassianProduct?: AtlassianProduct signingSecret?: string botToken?: string @@ -305,6 +306,7 @@ export async function createCredentialRecord( botToken: params.botToken, apiToken: params.apiToken, domain: params.domain, + providerWorkspaceId: params.providerWorkspaceId, atlassianProduct: params.atlassianProduct, serviceAccountJson: params.serviceAccountJson, clientId: params.clientId, diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 7bea5f98b54..73aab4fab51 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -82,6 +82,7 @@ const ROTATABLE_SECRET_FIELDS: readonly ServiceAccountFieldId[] = [ 'botToken', 'apiToken', 'domain', + 'providerWorkspaceId', 'atlassianProduct', 'clientId', 'clientSecret', @@ -200,6 +201,7 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { /** Atlassian service-account secret rotation (reconnect). */ apiToken?: string domain?: string + providerWorkspaceId?: string atlassianProduct?: AtlassianProduct /** Client-credential service-account secret rotation (reconnect). */ clientId?: string @@ -404,6 +406,7 @@ export async function updateCredentialRecord( botToken: params.botToken, apiToken: params.apiToken, domain: params.domain, + providerWorkspaceId: params.providerWorkspaceId, atlassianProduct: needsStoredAtlassianProduct ? readStoredField(storedBlob, 'atlassianProduct') === 'confluence' ? 'confluence' diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 01262f6d1cf..853679387a3 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -15,6 +15,7 @@ export type AtlassianProduct = (typeof ATLASSIAN_PRODUCTS)[number] export type ServiceAccountFieldId = | 'apiToken' | 'domain' + | 'providerWorkspaceId' | 'atlassianProduct' | 'serviceAccountJson' | 'signingSecret' diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index 198ff396fed..a060015a393 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -80,3 +80,15 @@ export function getServiceAccountConnectNoun(providerId: string): string { getTokenServiceAccountDescriptor(providerId) ?? getClientCredentialAccountDescriptor(providerId) return descriptor?.connectNoun ?? 'service account' } + +/** These provider credentials represent individual members in organization Search. */ +export const PERSONAL_ORGANIZATION_SERVICE_ACCOUNT_PROVIDER_IDS = [ + 'jama-service-account', + 'flow-service-account', +] as const + +export function isPersonalOrganizationServiceAccount( + providerId: string | null | undefined +): boolean { + return PERSONAL_ORGANIZATION_SERVICE_ACCOUNT_PROVIDER_IDS.some((id) => id === providerId) +} diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index ab0517825e7..4bf9d0b8768 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -54,6 +54,7 @@ export interface ServiceAccountSecretFields { botToken?: string apiToken?: string domain?: string + providerWorkspaceId?: string atlassianProduct?: AtlassianProduct serviceAccountJson?: string clientId?: string @@ -303,19 +304,22 @@ async function buildTokenServiceAccountSecret( const apiToken = fields.apiToken?.trim() const domain = fields.domain?.trim() const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') - if (!apiToken || (requiresDomain && !domain)) { + const providerWorkspaceId = fields.providerWorkspaceId?.trim() + const requiresWorkspace = descriptor.fields.some((field) => field.id === 'providerWorkspaceId') + if (!apiToken || (requiresDomain && !domain) || (requiresWorkspace && !providerWorkspaceId)) { const required = descriptor.fields.map((field) => field.id).join(' and ') throw new ServiceAccountSecretError( `${required} ${descriptor.fields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` ) } - const validation = await validator({ apiToken, domain }) + const validation = await validator({ apiToken, domain, providerWorkspaceId }) const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const blob: TokenServiceAccountSecretBlob = { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, providerId, apiToken, ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), + ...(requiresWorkspace ? { providerWorkspaceId } : {}), metadata: { ...validation.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 8ad598b81ba..d2e93b6ddbf 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -16,7 +16,7 @@ export const TOKEN_SERVICE_ACCOUNT_SECRET_TYPE = 'token_service_account' as const /** Contract field ids a token service-account modal may collect. */ -export type TokenServiceAccountFieldId = 'apiToken' | 'domain' +export type TokenServiceAccountFieldId = 'apiToken' | 'domain' | 'providerWorkspaceId' export interface TokenServiceAccountField { id: TokenServiceAccountFieldId @@ -81,7 +81,9 @@ export const PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'pipedrive-service-account' export const CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID = 'claude-platform-service-account' as const export const SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID = 'snowflake-service-account' as const +export const FLOW_SERVICE_ACCOUNT_PROVIDER_ID = 'flow-service-account' as const export const CODA_SERVICE_ACCOUNT_PROVIDER_ID = 'coda-service-account' as const +export const GAUGE_SERVICE_ACCOUNT_PROVIDER_ID = 'gauge-service-account' as const const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i @@ -111,6 +113,8 @@ export type TokenServiceAccountProviderId = | typeof CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID | typeof SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID | typeof CODA_SERVICE_ACCOUNT_PROVIDER_ID + | typeof GAUGE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof FLOW_SERVICE_ACCOUNT_PROVIDER_ID export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< TokenServiceAccountProviderId, @@ -409,6 +413,30 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/managed-agent', }, + [FLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: FLOW_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Flow Engineering', + tokenNoun: 'personal API key', + connectNoun: 'API key', + fields: [ + { id: 'apiToken', label: 'API key', placeholder: 'brd_...', secret: true }, + { + id: 'domain', + label: 'API host', + placeholder: 'backend.branch.flowengineering.com', + secret: false, + }, + { + id: 'providerWorkspaceId', + label: 'Flow workspace identifier', + placeholder: 'Workspace identifier from your MCP URL', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/flow', + helpText: + 'Copy your API key, API host, and workspace identifier from Settings → API & MCP in Flow.', + }, [CODA_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: CODA_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Coda', @@ -426,6 +454,27 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< helpText: 'Create a token under Account settings → API connections. A token restricted to specific docs or tables can only read and write those.', }, + [GAUGE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: GAUGE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Gauge', + tokenNoun: 'organization API key', + connectNoun: 'API key', + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'gauge_sk_...', + secret: true, + hintPattern: /^gauge_sk_/, + hintMessage: 'Gauge organization API keys start with gauge_sk_.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/gauge', + helpText: + 'Create a key in Gauge under Settings → Integrations → API Keys. Read only supports reports and reads; choose Read & write to manage prompts, topics, and tags. Reconnect with a new key after expiry or revocation.', + invalidCredentialsHelp: + 'Use a Gauge organization API key, not a CLI login token. Check that the key has not expired or been revoked.', + }, [SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Snowflake', @@ -485,11 +534,12 @@ export function getTokenServiceAccountDescriptor( * given provider. Provider-specific copy is inherited from the descriptor * (token noun, service label, and the optional `invalidCredentialsHelp` * override) rather than hard-coded in the shared connect modal. An - * unknown/absent code falls back to a generic retry message. + * unknown/absent code uses the caller's fallback or a generic retry message. */ export function getTokenServiceAccountErrorMessage( descriptor: TokenServiceAccountDescriptor, - code: string | undefined + code: string | undefined, + fallbackMessage = "We couldn't add this credential. Try again in a moment." ): string { switch (code) { case 'invalid_credentials': @@ -504,6 +554,6 @@ export function getTokenServiceAccountErrorMessage( case 'duplicate_display_name': return 'A credential with that name already exists in this workspace.' default: - return "We couldn't add this credential. Try again in a moment." + return fallbackMessage } } diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index 4e6daa6582d..c79f23e647f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -7,6 +7,8 @@ import { CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID, CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, CODA_SERVICE_ACCOUNT_PROVIDER_ID, + FLOW_SERVICE_ACCOUNT_PROVIDER_ID, + GAUGE_SERVICE_ACCOUNT_PROVIDER_ID, HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID, HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, isTokenServiceAccountProviderId, @@ -29,6 +31,8 @@ import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-ac import { validateClaudePlatformServiceAccount } from '@/lib/credentials/token-service-accounts/validators/claude-platform' import { validateClickupServiceAccount } from '@/lib/credentials/token-service-accounts/validators/clickup' import { validateCodaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/coda' +import { validateFlowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/flow' +import { validateGaugeServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gauge' import { validateHarmonicServiceAccount } from '@/lib/credentials/token-service-accounts/validators/harmonic' import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot' import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear' @@ -45,6 +49,7 @@ import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-a export interface TokenServiceAccountFields { apiToken: string domain?: string + providerWorkspaceId?: string } /** Result of a successful provider verification. */ @@ -103,6 +108,8 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID]: validateClaudePlatformServiceAccount, [SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: validateSnowflakeServiceAccount, [CODA_SERVICE_ACCOUNT_PROVIDER_ID]: validateCodaServiceAccount, + [GAUGE_SERVICE_ACCOUNT_PROVIDER_ID]: validateGaugeServiceAccount, + [FLOW_SERVICE_ACCOUNT_PROVIDER_ID]: validateFlowServiceAccount, } export function getTokenServiceAccountValidator( @@ -123,6 +130,7 @@ export interface TokenServiceAccountSecretBlob { providerId: string apiToken: string domain?: string + providerWorkspaceId?: string metadata?: Record } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/flow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/flow.ts new file mode 100644 index 00000000000..9e819f73bb0 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/flow.ts @@ -0,0 +1,65 @@ +import { toRecord } from '@sim/utils/object' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { userPrincipal } from '@/lib/credentials/principal' +import { + isTransientProviderStatus, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' +import { flowHeaders, normalizeFlowOrigin, normalizeFlowWorkspaceId } from '@/lib/flow/client' + +export async function validateFlowServiceAccount( + fields: TokenServiceAccountFields +): Promise { + let origin: string + let providerWorkspaceId: string + try { + origin = normalizeFlowOrigin(fields.domain) + providerWorkspaceId = normalizeFlowWorkspaceId(fields.providerWorkspaceId ?? '') + } catch { + throw new TokenServiceAccountValidationError('invalid_credentials', 400) + } + try { + const response = await secureFetchWithValidation(`${origin}/users/me`, { + profile: 'configuredEndpoint', + headers: flowHeaders({ accessToken: fields.apiToken, providerWorkspaceId }), + timeout: 10_000, + maxResponseBytes: 64 * 1024, + maxRedirects: 0, + logUrlValidationDetails: false, + }) + if (!response.ok) { + await response.body?.cancel() + throw new TokenServiceAccountValidationError( + response.status >= 400 && + response.status < 500 && + !isTransientProviderStatus(response.status) + ? 'invalid_credentials' + : 'provider_unavailable', + response.status + ) + } + const person = toRecord(await response.json()) + if ( + typeof person.id !== 'string' || + !person.id.trim() || + typeof person.email !== 'string' || + !person.email.trim() || + person.deactivated_at !== null + ) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400) + } + return { + displayName: person.email, + principal: person.is_bot === true ? null : userPrincipal(person.id, person.email), + normalizedDomain: origin, + auditMetadata: { providerWorkspaceId }, + } + } catch (error) { + if (error instanceof TokenServiceAccountValidationError) throw error + throw new TokenServiceAccountValidationError('provider_unavailable', 502) + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/gauge.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/gauge.test.ts new file mode 100644 index 00000000000..46f8897f64e --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/gauge.test.ts @@ -0,0 +1,81 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { describe, expect, it, vi } from 'vitest' +import { validateGaugeServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gauge' + +const credentialInfo = { + user: null, + apiKey: { + id: 'key-example', + name: 'Workflow key', + prefix: 'gauge_sk_example', + organization: { id: 'org-example', slug: 'example', name: 'Example' }, + }, + token: { kind: 'api_key', scopes: ['read'], expiresAt: null }, +} + +describe('Gauge saved API key verification', () => { + it('records an organization principal instead of inventing a human actor', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(credentialInfo))) + + const result = await validateGaugeServiceAccount({ apiToken: 'gauge_sk_test' }) + + expect(result.principal).toEqual({ kind: 'tenant', id: 'org-example', label: 'Example' }) + expect(result.storedMetadata).toMatchObject({ keyId: 'key-example', scopes: '["read"]' }) + }) + + it.each([ + { + name: 'a user login token', + value: { + user: { id: 'user-example', name: 'Example User', email: 'user@example.test' }, + apiKey: null, + token: { kind: 'session', scopes: ['read', 'write'], expiresAt: null }, + }, + code: 'invalid_credentials', + }, + { + name: 'an expired key', + value: { + ...credentialInfo, + token: { ...credentialInfo.token, expiresAt: '2000-01-01T00:00:00Z' }, + }, + code: 'invalid_credentials', + }, + { + name: 'a successful body without an organization identity', + value: { ...credentialInfo, apiKey: { ...credentialInfo.apiKey, organization: null } }, + code: 'provider_unavailable', + }, + ])('rejects $name', async ({ value, code }) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(value))) + + await expect(validateGaugeServiceAccount({ apiToken: 'gauge_sk_test' })).rejects.toMatchObject({ + code, + }) + }) + + it.each([ + { status: 401, code: 'invalid_credentials' }, + { status: 429, code: 'provider_unavailable' }, + ])('keeps a provider $status body out of validation errors', async ({ status, code }) => { + const privateBody = 'private provider response with gauge_sk_test' + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(privateBody, { status }))) + + const error = await validateGaugeServiceAccount({ apiToken: 'gauge_sk_test' }).catch( + (error: unknown) => error + ) + + expect(error).toMatchObject({ code }) + expect(JSON.stringify(error)).not.toContain(privateBody) + expect(JSON.stringify(error)).not.toContain('gauge_sk_test') + }) + + it('bounds identity response bytes before parsing provider data', async () => { + const body = JSON.stringify({ ...credentialInfo, extra: 'x'.repeat(65_536) }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))) + + await expect(validateGaugeServiceAccount({ apiToken: 'gauge_sk_test' })).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/gauge.ts b/apps/sim/lib/credentials/token-service-accounts/validators/gauge.ts new file mode 100644 index 00000000000..e4abb105a75 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/gauge.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { tenantPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + isTransientProviderStatus, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +const GAUGE_ME_URL = 'https://app.withgauge.com/api/v1/me' +const MAX_IDENTITY_RESPONSE_BYTES = 64 * 1024 + +const credentialSchema = z.object({ + user: z.unknown(), + apiKey: z + .object({ + id: z.string().min(1), + name: z.string().min(1), + organization: z.object({ + id: z.string().min(1), + name: z.string().min(1), + slug: z.string().min(1), + }), + }) + .nullable(), + token: z.object({ + kind: z.enum(['session', 'api_key']), + scopes: z.array(z.string()), + expiresAt: z.iso.datetime({ offset: true }).nullable(), + }), +}) + +/** Verifies a saved Gauge organization API key without accepting temporary CLI login tokens. */ +export async function validateGaugeServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const response = await fetchProvider( + GAUGE_ME_URL, + { + headers: { Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json' }, + redirect: 'error', + }, + 'me' + ) + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + const callerError = + response.status >= 400 && response.status < 500 && !isTransientProviderStatus(response.status) + throw new TokenServiceAccountValidationError( + callerError ? 'invalid_credentials' : 'provider_unavailable', + response.status, + { step: 'me', status: response.status } + ) + } + + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: MAX_IDENTITY_RESPONSE_BYTES, + label: 'Gauge credential identity', + }) + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'me' }) + } + const result = credentialSchema.safeParse(payload) + if (!result.success) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'me' }) + } + const { apiKey, token, user } = result.data + if ( + token.kind !== 'api_key' || + !apiKey || + user !== null || + (token.expiresAt !== null && Date.parse(token.expiresAt) <= Date.now()) + ) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'me' }) + } + + return { + displayName: `${apiKey.name} (${apiKey.organization.name})`, + principal: tenantPrincipal(apiKey.organization.id, apiKey.organization.name), + auditMetadata: { gaugeKeyId: apiKey.id }, + storedMetadata: { + keyId: apiKey.id, + organizationId: apiKey.organization.id, + organizationSlug: apiKey.organization.slug, + scopes: JSON.stringify(token.scopes), + ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}), + }, + } +} diff --git a/apps/sim/lib/execution/files.ts b/apps/sim/lib/execution/files.ts index 7164ee1fde7..2c99dfd9910 100644 --- a/apps/sim/lib/execution/files.ts +++ b/apps/sim/lib/execution/files.ts @@ -267,7 +267,7 @@ export async function processExecutionFiles( uploadedFiles.push(userFile) } } catch (error) { - logger.error(`[${requestId}] Failed to process workflow file`, error) + logger.error('Failed to process workflow file', error, { requestId }) throw new Error(`Failed to process workflow file: ${getErrorMessage(error, 'Invalid file')}`) } } @@ -384,7 +384,8 @@ export async function processInputFileFields( else processedInput[fileField.name] = uploadedFiles if (uploadedFiles.length > 0) { logger.info( - `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` + `Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}`, + { requestId } ) } } diff --git a/apps/sim/lib/execution/isolated-vm.test.ts b/apps/sim/lib/execution/isolated-vm.test.ts index c1f04e9e47f..af76efff66b 100644 --- a/apps/sim/lib/execution/isolated-vm.test.ts +++ b/apps/sim/lib/execution/isolated-vm.test.ts @@ -1136,7 +1136,8 @@ describe('isolated-vm scheduler', () => { expect(JSON.parse(String(result.result)).error).toContain(secret) expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(secret) - expect(mockLogger.warn).toHaveBeenCalledWith('[req-fetch-secret] Isolated fetch failed', { + expect(mockLogger.warn).toHaveBeenCalledWith('Isolated fetch failed', { + requestId: 'req-fetch-secret', errorName: 'Error', }) }) diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index 66ac6ce06d3..b0e6379e169 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -416,9 +416,7 @@ async function secureFetch( }) } catch (error: unknown) { const normalizedError = toError(error) - logger.warn(`[${requestId}] Isolated fetch failed`, { - errorName: normalizedError.name, - }) + logger.warn('Isolated fetch failed', { requestId, errorName: normalizedError.name }) return JSON.stringify({ error: getErrorMessage(error, 'Unknown fetch error') }) } } diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index 1fc17b1f446..b52dbc871ee 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -2,6 +2,7 @@ import type { Principal } from '@sim/auth/principal' import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { getExecutionKeyParts } from '@/lib/execution/payloads/access-keys' @@ -398,7 +399,7 @@ export async function readUserFileContentWithContributors( let contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] | undefined let renderedContributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] | undefined const log = getLogger(options) - const requestId = options.requestId ?? 'unknown' + const requestId = options.requestId ?? generateRequestId() try { const servable = await downloadServableFileFromStorage(file, requestId, log, { diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index d51a554f8dc..d8821ae3844 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -15,6 +15,7 @@ import { billingUsageReservationMockFns, } from '@sim/testing/mocks/billing-usage-reservation.mock' import { executionLimitsMock } from '@sim/testing/mocks/execution-limits.mock' +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { loggingSessionMockFns } from '@sim/testing/mocks/logging-session.mock' import { createMockRedis } from '@sim/testing/mocks/redis.mock' import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing/mocks/redis-config.mock' @@ -1123,6 +1124,22 @@ describe('preprocessExecution admission rejection codes and blocked-run log thro expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledTimes(1) }) + it('logs one warning for repeated refusals of a workflow by the same gate', async () => { + const workflowId = 'workflow-1' + const logger = getMockLogger('ExecutionPreprocessing') + logger.warn.mockClear() + + for (let attempt = 0; attempt < 3; attempt++) { + await refuse(workflowId, { throttleErrorLogs: true }) + } + + expect(logger.warn).toHaveBeenCalledOnce() + expect(logger.warn).toHaveBeenCalledWith( + 'Attributed usage gate blocked actor', + expect.objectContaining({ workflowId }) + ) + }) + it('writes a row for a different gate refusing the same workflow inside the window', async () => { const workflowId = 'workflow-1' await refuse(workflowId, { throttleErrorLogs: true }) diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 0d4de3132a3..8c1520b097d 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -362,6 +362,12 @@ function startAdmissionReads(params: { } } +/** A gate refusal's warning, emitted alongside its error row so both share the throttle window. */ +interface GateWarning { + message: string + fields: Record +} + export async function preprocessExecution( options: PreprocessExecutionOptions ): Promise { @@ -401,11 +407,15 @@ export async function preprocessExecution( const isFailureLogSuppressed = (failure: PreprocessExecutionError): boolean => suppressRetryableFailureLogs && failure.statusCode >= 500 && failure.retryable === true - /** Records an admission gate's error row, at most once per gate and outcome per window when throttled. */ + /** + * Records an admission gate's warning and error row, at most once per gate and outcome per window + * when throttled, so a sender redelivering into a refusal does not log every attempt. + */ const recordGateFailure = async ( gate: 'ban' | 'usage' | 'rate-limit' | 'reservation', failure: PreprocessExecutionError, - record: Parameters[0] + record: Parameters[0], + warning?: GateWarning ): Promise => { if (isFailureLogSuppressed(failure)) return if ( @@ -416,10 +426,13 @@ export async function preprocessExecution( ) { return } + if (warning) + logger.warn(warning.message, { requestId, workflowId, triggerType, ...warning.fields }) await recordPreprocessingError(record) } - logger.info(`[${requestId}] Starting execution preprocessing`, { + logger.debug('Starting execution preprocessing', { + requestId, workflowId, userId, triggerType, @@ -427,7 +440,8 @@ export async function preprocessExecution( }) if (prefetchedWorkflowRecord && prefetchedWorkflowRecord.id !== workflowId) { - logger.error(`[${requestId}] Prefetched workflow record ID mismatch`, { + logger.error('Prefetched workflow record ID mismatch', { + requestId, expected: workflowId, received: prefetchedWorkflowRecord.id, }) @@ -454,7 +468,7 @@ export async function preprocessExecution( }) if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + logger.warn(`Workflow not found: ${workflowId}`, { requestId }) await recordPreprocessingError({ workflowId, @@ -479,7 +493,7 @@ export async function preprocessExecution( } } } catch (error) { - logger.error(`[${requestId}] Error fetching workflow`, { error, workflowId }) + logger.error('Error fetching workflow', { requestId, error, workflowId }) const failure: PreprocessExecutionError = { message: 'Internal error while fetching workflow', @@ -504,7 +518,7 @@ export async function preprocessExecution( return { success: false, error: failure } } } else if (workflowRecord.archivedAt) { - logger.warn(`[${requestId}] Prefetched workflow is archived: ${workflowId}`) + logger.warn(`Prefetched workflow is archived: ${workflowId}`, { requestId }) return { success: false, error: { @@ -518,7 +532,7 @@ export async function preprocessExecution( label: 'getActiveWorkflowRecord', }) if (!activeWorkflow) { - logger.warn(`[${requestId}] Workflow archived before execution started: ${workflowId}`) + logger.warn(`Workflow archived before execution started: ${workflowId}`, { requestId }) return { success: false, error: { @@ -534,7 +548,7 @@ export async function preprocessExecution( const workspaceId = workflowRecord.workspaceId || providedWorkspaceId || '' if (!workspaceId) { - logger.warn(`[${requestId}] Workflow ${workflowId} has no workspaceId; execution blocked`) + logger.warn(`Workflow ${workflowId} has no workspaceId; execution blocked`, { requestId }) return { success: false, error: { @@ -548,7 +562,7 @@ export async function preprocessExecution( /** Undeployed workflows are rejected without creating an execution or cost log. */ if (checkDeployment && !workflowRecord.isDeployed) { - logger.warn(`[${requestId}] Workflow not deployed: ${workflowId}`) + logger.warn(`Workflow not deployed: ${workflowId}`, { requestId }) return { success: false, @@ -577,7 +591,8 @@ export async function preprocessExecution( } actorUserId = validatedAttribution.actorUserId billingAttribution = validatedAttribution - logger.info(`[${requestId}] Reusing serialized billing attribution`, { + logger.debug('Reusing serialized billing attribution', { + requestId, actorUserId, billingEntity: billingAttribution.billingEntity, }) @@ -585,13 +600,14 @@ export async function preprocessExecution( if (!actorUserId && useAuthenticatedUserAsActor && userId) { actorUserId = userId - logger.info(`[${requestId}] Using authenticated user as actor: ${actorUserId}`) + logger.debug(`Using authenticated user as actor: ${actorUserId}`, { requestId }) } if (!actorUserId) { billingAttribution = await admission.attributionFor(workspaceId) actorUserId = billingAttribution.actorUserId - logger.info(`[${requestId}] Using atomically resolved system actor and payer`, { + logger.debug('Using atomically resolved system actor and payer', { + requestId, actorUserId, billingEntity: billingAttribution.billingEntity, }) @@ -599,7 +615,8 @@ export async function preprocessExecution( if (!actorUserId) { const errorLogUserId = userId || 'unknown' - logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, { + logger.warn(BILLING_ERROR_MESSAGES.BILLING_REQUIRED, { + requestId, workflowId, workspaceId, }) @@ -629,7 +646,7 @@ export async function preprocessExecution( billingAttribution = await admission.attributionFor(workspaceId) } } catch (error) { - logger.error(`[${requestId}] Error resolving billing attribution`, { error, workflowId }) + logger.error('Error resolving billing attribution', { requestId, error, workflowId }) const errorLogUserId = userId || 'unknown' const failure: PreprocessExecutionError = { message: 'Error resolving billing account', @@ -672,6 +689,7 @@ export async function preprocessExecution( interface GateFailure { response: PreprocessExecutionFailure recordError?: Parameters[0] + warning?: GateWarning } /** Usage figures captured by the read gate and reused by the atomic reservation. */ @@ -690,13 +708,8 @@ export async function preprocessExecution( try { const bannedUserIds = await gateReads.bannedUserIds if (bannedUserIds.length > 0) { - logger.warn(`[${requestId}] Execution blocked: banned account`, { - workflowId, - bannedUserIds, - triggerType, - }) - return { + warning: { message: 'Execution blocked: banned account', fields: { bannedUserIds } }, response: { success: false, error: { @@ -720,7 +733,7 @@ export async function preprocessExecution( } return null } catch (error) { - logger.error(`[${requestId}] Error checking account ban status`, { error, actorUserId }) + logger.error('Error checking account ban status', { requestId, error, actorUserId }) return { response: { @@ -774,16 +787,17 @@ export async function preprocessExecution( } : null if (usageCheck.isExceeded) { - logger.warn(`[${requestId}] Attributed usage gate blocked actor ${actorUserId}.`, { - currentUsage: snapshot?.currentUsage, - limit: snapshot?.limit, - scope: usageCheck.scope, - workflowId, - triggerType, - }) - return { failure: { + warning: { + message: 'Attributed usage gate blocked actor', + fields: { + actorUserId, + currentUsage: snapshot?.currentUsage, + limit: snapshot?.limit, + scope: usageCheck.scope, + }, + }, response: { success: false, error: { @@ -816,10 +830,7 @@ export async function preprocessExecution( } return { failure: null, snapshot } } catch (error) { - logger.error(`[${requestId}] Error checking usage limits`, { - error, - actorUserId, - }) + logger.error('Error checking usage limits', { requestId, error, actorUserId }) return { failure: { @@ -876,13 +887,11 @@ export async function preprocessExecution( ) if (!info.allowed) { - logger.warn(`[${requestId}] Rate limit exceeded for user ${actorUserId}`, { - triggerType, - remaining: info.remaining, - resetAt: info.resetAt, - }) - return { + warning: { + message: 'Rate limit exceeded', + fields: { actorUserId, remaining: info.remaining, resetAt: info.resetAt }, + }, response: { success: false, error: { @@ -912,7 +921,7 @@ export async function preprocessExecution( } return null } catch (error) { - logger.error(`[${requestId}] Error checking rate limits`, { error, actorUserId }) + logger.error('Error checking rate limits', { requestId, error, actorUserId }) return { response: { @@ -947,7 +956,8 @@ export async function preprocessExecution( await recordGateFailure( banFailure ? 'ban' : 'usage', readGateFailure.response.error, - readGateFailure.recordError + readGateFailure.recordError, + readGateFailure.warning ) } return readGateFailure.response @@ -959,7 +969,8 @@ export async function preprocessExecution( await recordGateFailure( 'rate-limit', rateLimitFailure.response.error, - rateLimitFailure.recordError + rateLimitFailure.recordError, + rateLimitFailure.warning ) } return rateLimitFailure.response @@ -1009,12 +1020,6 @@ export async function preprocessExecution( reservation.reason ) const message = RESERVATION_DENIAL_MESSAGE[reservation.reason] - logger.warn(`[${requestId}] Admission reservation full for user ${actorUserId}`, { - workflowId, - triggerType, - constraint: reservation.reason, - }) - const failure: PreprocessExecutionError = { message, statusCode: descriptor.statusCode, @@ -1026,22 +1031,31 @@ export async function preprocessExecution( constraint: reservation.reason, }, } - await recordGateFailure('reservation', failure, { - workflowId, - executionId, - triggerType, - requestId, - userId: actorUserId, - workspaceId, - errorMessage: message, - loggingSession: providedLoggingSession, - triggerData, - }) + await recordGateFailure( + 'reservation', + failure, + { + workflowId, + executionId, + triggerType, + requestId, + userId: actorUserId, + workspaceId, + errorMessage: message, + loggingSession: providedLoggingSession, + triggerData, + }, + { + message: 'Admission reservation full', + fields: { actorUserId, constraint: reservation.reason }, + } + ) return { success: false, error: failure } } } catch (error) { - logger.error(`[${requestId}] Admission reservation infrastructure unavailable`, { + logger.error('Admission reservation infrastructure unavailable', { + requestId, error, actorUserId, }) @@ -1068,7 +1082,8 @@ export async function preprocessExecution( } } - logger.info(`[${requestId}] All preprocessing checks passed`, { + logger.debug('All preprocessing checks passed', { + requestId, workflowId, actorUserId, triggerType, @@ -1133,7 +1148,8 @@ async function logPreprocessingError(params: { } = params if (!workspaceId) { - logger.warn(`[${requestId}] Cannot log preprocessing error: no workspaceId available`, { + logger.warn('Cannot log preprocessing error: no workspaceId available', { + requestId, workflowId, executionId, errorMessage, @@ -1161,10 +1177,6 @@ async function logPreprocessingError(params: { skipCost: true, }) } catch (error) { - logger.error(`[${requestId}] Failed to log preprocessing error`, { - error, - workflowId, - executionId, - }) + logger.error('Failed to log preprocessing error', { requestId, error, workflowId, executionId }) } } diff --git a/apps/sim/lib/file-retention/legacy-archive.ts b/apps/sim/lib/file-retention/legacy-archive.ts index 28e1b5afa7a..d90316427f4 100644 --- a/apps/sim/lib/file-retention/legacy-archive.ts +++ b/apps/sim/lib/file-retention/legacy-archive.ts @@ -192,7 +192,7 @@ async function deleteExpiredLegacyWorkspaceFileRows( result.failed += batch.length - deleted.length } catch (error) { result.failed += batch.length - logger.error(`[${label}/workspaceFile] Exact-row delete failed`, { error }) + logger.error('Exact-row delete failed', { label: `${label}/workspaceFile`, error }) } } return result @@ -233,7 +233,10 @@ async function deleteExpiredUnbilledWorkspaceFileRows( result.failed += batch.length - deleted.length } catch (error) { result.failed += batch.length - logger.error(`[${label}/workspaceFiles] Exact-row ${context} delete failed`, { error }) + logger.error(`Exact-row ${context} delete failed`, { + label: `${label}/workspaceFiles`, + error, + }) } } } @@ -251,7 +254,8 @@ async function deleteExpiredBillableWorkspaceFileRows( if (!BILLED_FILE_CONTEXTS.includes(row.context)) continue if (!row.workspaceId) { result.failed++ - logger.error(`[${label}/workspaceFiles] Billable row has no workspace attribution`, { + logger.error('Billable row has no workspace attribution', { + label: `${label}/workspaceFiles`, fileId: row.id, }) continue @@ -333,7 +337,8 @@ async function deleteExpiredBillableWorkspaceFileRows( result.failed += batch.length - deletedCount } catch (error) { result.failed += batch.length - logger.error(`[${label}/workspaceFiles] Atomic delete and decrement failed`, { + logger.error('Atomic delete and decrement failed', { + label: `${label}/workspaceFiles`, error, workspaceId, }) diff --git a/apps/sim/lib/flow/client.ts b/apps/sim/lib/flow/client.ts new file mode 100644 index 00000000000..b851c89ba33 --- /dev/null +++ b/apps/sim/lib/flow/client.ts @@ -0,0 +1,50 @@ +const FLOW_API_ORIGIN = 'https://backend.branch.flowengineering.com' + +/** Normalizes the origin copied from Flow's API settings without accepting request paths. */ +export function normalizeFlowOrigin(domain?: string): string { + const value = domain?.trim() || FLOW_API_ORIGIN + let url: URL + try { + url = new URL(value.includes('://') ? value : `https://${value}`) + } catch { + throw new Error('Enter a valid Flow API URL') + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + !['/', '/customer-api', '/customer-api/'].includes(url.pathname) + ) { + throw new Error( + 'Flow API URL must be an HTTPS origin or the API settings URL ending in /customer-api' + ) + } + return url.origin +} + +/** Rejects invalid header values while preserving Flow's workspace identifier. */ +export function normalizeFlowWorkspaceId(value: string): string { + const workspaceId = value.trim() + if (!workspaceId || workspaceId.length > 256 || /[\u0000-\u0020\u007f]/.test(workspaceId)) { + throw new Error('Enter the Flow workspace identifier from the end of its MCP URL') + } + if (/[/?#\\]/.test(workspaceId)) { + throw new Error('Enter the Flow workspace identifier, not a URL') + } + return workspaceId +} + +/** Shared authentication headers for Flow tools, selectors, and credential verification. */ +export function flowHeaders(params: { + accessToken: string + providerWorkspaceId: string +}): Record { + return { + 'X-API-Key': params.accessToken, + customer: normalizeFlowWorkspaceId(params.providerWorkspaceId), + Accept: 'application/json', + 'Content-Type': 'application/json', + } +} diff --git a/apps/sim/lib/flow/index.ts b/apps/sim/lib/flow/index.ts new file mode 100644 index 00000000000..21f7ec40185 --- /dev/null +++ b/apps/sim/lib/flow/index.ts @@ -0,0 +1,2 @@ +export { flowHeaders, normalizeFlowOrigin, normalizeFlowWorkspaceId } from './client' +export type * from './types' diff --git a/apps/sim/lib/flow/types.ts b/apps/sim/lib/flow/types.ts new file mode 100644 index 00000000000..30e1624715e --- /dev/null +++ b/apps/sim/lib/flow/types.ts @@ -0,0 +1,227 @@ +/** Provider response contracts from https://docs.flowengineering.com/openapi/customer-api.json. */ + +export interface FlowProject { + ai_model: string + created_at: string + id: string + name: string + slug: string +} + +export interface FlowSearchResult { + entity_id: string + entity_type: string + human_id: string | null + legacy_human_id: string | null + match?: 'semantic' + name: string + same_text_count?: number +} + +export interface FlowEntity { + branchesChangedIn?: Array + branchesCreatedIn?: Array + createdAt?: string + defaultSortKey?: string + deleted?: boolean + formulaTemplates: Record + formulas: Record + id: string + links: FlowEntityLinks + type?: string + uniqueEntityNumber?: number + updatedAt?: string + values: Record +} + +interface FlowEntityLinks { + crosslinks: Array + inbound: Array + inboundOrder: Record + inboundTypes?: Record + outbound: Array + outboundOrder?: Record + outboundTypes?: Record + rootPlaced?: boolean +} + +export interface FlowEntityPage { + items: Array + moreAvailable?: boolean + nextPageStart?: string +} + +export interface FlowCategory { + builtin: boolean + categoryType: 'folder' | 'item' + fields: Array + humanIdPrefix: string + humanIdPrefixLocked: boolean + id: string + name: string +} + +interface FlowField { + key: string + multi?: boolean + name: string + tags?: Array + type: string +} + +interface FlowTag { + id?: string + name: string +} + +export interface FlowBranch { + created_at: string + description: string + id: string + is_deleted: boolean + is_protected: boolean + live_edits_allowed: boolean + merged_at: string | null + name: string + number: number + owner_id: string | null + parent?: string | null + parent_name: string | null + ready_for_review: boolean + require_review: boolean + required_reviewer_ids: Array + updated_at: string +} + +export interface FlowComment { + author_id: string + branch_id?: string + created_at: string + entity_id: string + id: string + resolved: boolean + text: string +} + +export interface FlowHistoryEntry { + actor: FlowEntityHistoryActor + branch_id: string + change: + | FlowEntityCreatedChange + | FlowEntityValueChangedChange + | FlowEntityFormulaChangedChange + | FlowEntityLinkAddedChange + | FlowEntityLinkRemovedChange + | FlowEntityCrosslinkAddedChange + | FlowEntityCrosslinkRemovedChange + | FlowEntityDeletedChange + | FlowEntityRestoredChange + | FlowEntityChildReorderedChange + | FlowEntityMigrationNoteChange + id: string + reason?: + | FlowFormulaRecomputedReason + | FlowFieldTypeChangedReason + | FlowDefaultFormulaChangedReason + | FlowDefaultFormulaReappliedReason + timestamp: string +} + +interface FlowEntityHistoryActor { + id?: string + type: 'user' | 'agent' | 'system' | 'unknown' +} + +interface FlowEntityCreatedChange { + entity_id: string + entity_type: string + type: 'entity_created' +} + +interface FlowEntityValueChangedChange { + entity_id: string + field_key: string + type: 'value_changed' + value: string | number | boolean | Record | Array | null +} + +interface FlowEntityFormulaChangedChange { + entity_id: string + field_key: string + formula: string | null + type: 'formula_changed' +} + +interface FlowEntityLinkAddedChange { + child_entity_id: string + parent_entity_id: string + type: 'link_added' +} + +interface FlowEntityLinkRemovedChange { + child_entity_id: string + parent_entity_id: string + type: 'link_removed' +} + +interface FlowEntityCrosslinkAddedChange { + entity_ids: Array + type: 'crosslink_added' +} + +interface FlowEntityCrosslinkRemovedChange { + entity_ids: Array + type: 'crosslink_removed' +} + +interface FlowEntityDeletedChange { + entity_id: string + type: 'entity_deleted' +} + +interface FlowEntityRestoredChange { + entity_id: string + type: 'entity_restored' +} + +interface FlowEntityChildReorderedChange { + child_entity_id: string + parent_entity_id: string + type: 'child_reordered' +} + +interface FlowEntityMigrationNoteChange { + action?: string + note?: string + source_entity_id?: string + source_name?: string + type: 'migration_note' +} + +interface FlowFormulaRecomputedReason { + source_entity_id?: string + source_field_key?: string + type: 'formula_recomputed' +} + +interface FlowFieldTypeChangedReason { + field_name: string + next_type: string + previous_type: string + type: 'field_type_changed' +} + +interface FlowDefaultFormulaChangedReason { + field_name: string + type: 'default_formula_changed' +} + +interface FlowDefaultFormulaReappliedReason { + field_name: string + type: 'default_formula_reapplied' +} + +export interface FlowHistoryPage { + items: Array + next_cursor: string | null +} diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 7b50c729753..228060601f3 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -2467,7 +2467,8 @@ export async function executeFunctionRequest( const executionParams = { ...params } executionParams._context = undefined - logger.debug(`[${requestId}] Function execution request`, { + logger.debug('Function execution request', { + requestId, hasCode: !!code, paramsCount: Object.keys(executionParams).length, timeout, @@ -2724,7 +2725,8 @@ export async function executeFunctionRequest( // messages already say which file and what to do, so they are the response // rather than a 500 that reads like the platform broke. Matches the // too-many-files refusal above. - logger.warn(`[${requestId}] Could not resolve sandbox file mounts`, { + logger.warn('Could not resolve sandbox file mounts', { + requestId, error: getErrorMessage(error), }) return functionJsonResponse( @@ -2800,9 +2802,7 @@ export async function executeFunctionRequest( : undefined if (mountManifest.length > 0) { - logger.info(`[${requestId}] Mounted files into sandbox`, { - mountCount: mountManifest.length, - }) + logger.info('Mounted files into sandbox', { requestId, mountCount: mountManifest.length }) } if (lang === CodeLanguage.Shell) { @@ -2814,7 +2814,8 @@ export async function executeFunctionRequest( shellEnvs[k] = serializeForShellEnv(v, 'null') } - logger.info(`[${requestId}] E2B shell execution`, { + logger.info('E2B shell execution', { + requestId, enabled: remoteSandboxEnabled, hasApiKey: Boolean(process.env.E2B_API_KEY), envVarCount: Object.keys(shellEnvs).length, @@ -2852,7 +2853,8 @@ export async function executeFunctionRequest( }) const executionTime = Date.now() - execStart - logger.info(`[${requestId}] E2B shell sandbox`, { + logger.info('E2B shell sandbox', { + requestId, sandboxId, succeeded: !shellError, executionTime, @@ -2931,7 +2933,8 @@ export async function executeFunctionRequest( } if (useRemoteSandbox) { - logger.info(`[${requestId}] E2B status`, { + logger.info('E2B status', { + requestId, enabled: remoteSandboxEnabled, hasApiKey: Boolean(process.env.E2B_API_KEY), language: lang, @@ -3011,7 +3014,8 @@ export async function executeFunctionRequest( const executionTime = Date.now() - execStart stdout += e2bStdout - logger.info(`[${requestId}] E2B JS sandbox`, { + logger.info('E2B JS sandbox', { + requestId, sandboxId, succeeded: !e2bError, executionTime, @@ -3143,7 +3147,8 @@ export async function executeFunctionRequest( const executionTime = Date.now() - execStart stdout += e2bStdout - logger.info(`[${requestId}] E2B Py sandbox`, { + logger.info('E2B Py sandbox', { + requestId, sandboxId, succeeded: !e2bError, executionTime, @@ -3315,7 +3320,8 @@ export async function executeFunctionRequest( /** The author's code throwing is logged once by the tool boundary; an isolate failure is ours. */ if (isSystemError) { - logger.error(`[${requestId}] Function execution failed in isolated-vm`, { + logger.error('Function execution failed in isolated-vm', { + requestId, executionTime, hasStack: Boolean(ivmError.stack), line: enhancedError.line, @@ -3345,7 +3351,8 @@ export async function executeFunctionRequest( ) } - logger.debug(`[${requestId}] Function executed successfully using ${executionMethod}`, { + logger.debug(`Function executed successfully using ${executionMethod}`, { + requestId, executionTime, }) @@ -3384,7 +3391,8 @@ export async function executeFunctionRequest( executionTime, }, } - logger.warn(`[${requestId}] Function execution ${timedOut ? 'timed out' : 'was cancelled'}`, { + logger.warn(`Function execution ${timedOut ? 'timed out' : 'was cancelled'}`, { + requestId, executionTime, }) return routeContext @@ -3440,7 +3448,8 @@ export async function executeFunctionRequest( ) } if (isExecutionResourceLimitError(error)) { - logger.warn(`[${requestId}] Function execution exceeded resource limits`, { + logger.warn('Function execution exceeded resource limits', { + requestId, resource: error.resource, attemptedBytes: error.attemptedBytes, limitBytes: error.limitBytes, @@ -3501,7 +3510,8 @@ export async function executeFunctionRequest( (error?.message || String(error)).slice(0, 300), compilerInternalIdentifiers ) - logger.warn(`[${requestId}] Sandbox terminated before completion (likely OOM or timeout)`, { + logger.warn('Sandbox terminated before completion (likely OOM or timeout)', { + requestId, executionTime, }) const killResponse = { @@ -3527,7 +3537,8 @@ export async function executeFunctionRequest( compilerInternalIdentifiers ) - logger.error(`[${requestId}] Function execution failed`, { + logger.error('Function execution failed', { + requestId, executionTime, hasStack: Boolean(error.stack), line: enhancedError.line, diff --git a/apps/sim/lib/guardrails/validate-hallucination.ts b/apps/sim/lib/guardrails/validate-hallucination.ts index cf21d8d8648..d87d1387070 100644 --- a/apps/sim/lib/guardrails/validate-hallucination.ts +++ b/apps/sim/lib/guardrails/validate-hallucination.ts @@ -101,9 +101,7 @@ async function queryKnowledgeBase( } } catch (error) { const message = getErrorMessage(error, 'Unknown Knowledge query error') - logger.error(`[${requestId}] Error querying knowledge base`, { - error: message, - }) + logger.error('Error querying knowledge base', { requestId, error: message }) throw new Error(`Failed to query knowledge base: ${message}`, { cause: error }) } } @@ -153,7 +151,8 @@ ${userInput} Evaluate the consistency and provide your score and reasoning in JSON format.` - logger.info(`[${requestId}] Calling LLM for hallucination scoring`, { + logger.info('Calling LLM for hallucination scoring', { + requestId, model, contextChunks: ragContext.length, }) @@ -228,9 +227,7 @@ Evaluate the consistency and provide your score and reasoning in JSON format.` throw new Error('Invalid score format from LLM') } - logger.info(`[${requestId}] Confidence score: ${result.score}/10`, { - reasoning: result.reasoning, - }) + logger.info(`Confidence score: ${result.score}/10`, { requestId, reasoning: result.reasoning }) return { score: result.score, @@ -243,9 +240,7 @@ Evaluate the consistency and provide your score and reasoning in JSON format.` * `AbortError` name the outer handler classifies on, so it propagates as-is. */ if (isAbortError(error)) throw error - logger.error(`[${requestId}] Error scoring with LLM`, { - error: getErrorMessage(error), - }) + logger.error('Error scoring with LLM', { requestId, error: getErrorMessage(error) }) throw new Error(`Failed to score confidence: ${getErrorMessage(error)}`) } } @@ -351,10 +346,7 @@ export async function validateHallucination( abortSignal ) - logger.info(`[${requestId}] Confidence score: ${score}`, { - reasoning, - threshold, - }) + logger.info(`Confidence score: ${score}`, { requestId, reasoning, threshold }) // Step 3: Check against threshold. Lower scores = less confidence = fail validation const passed = score >= threshold @@ -375,9 +367,7 @@ export async function validateHallucination( * indistinguishable to a consumer from the model actually hallucinating. */ if (isAbortError(error)) throw error - logger.error(`[${requestId}] Hallucination validation error`, { - error: getErrorMessage(error), - }) + logger.error('Hallucination validation error', { requestId, error: getErrorMessage(error) }) return { passed: false, error: `Validation error: ${getErrorMessage(error)}`, diff --git a/apps/sim/lib/guardrails/validate-pii.ts b/apps/sim/lib/guardrails/validate-pii.ts index d2d0cc25472..962870c61e6 100644 --- a/apps/sim/lib/guardrails/validate-pii.ts +++ b/apps/sim/lib/guardrails/validate-pii.ts @@ -425,7 +425,8 @@ async function anonymize( export async function validatePII(input: PIIValidationInput): Promise { const { text, entityTypes, mode, language = 'en', customPatterns, requestId, abortSignal } = input - logger.info(`[${requestId}] Starting PII validation`, { + logger.info('Starting PII validation', { + requestId, textLength: text.length, entityTypes, mode, @@ -448,7 +449,7 @@ export async function validatePII(input: PIIValidationInput): Promise `${count} ${type}`) .join(', ') - logger.info(`[${requestId}] PII validation completed`, { + logger.info('PII validation completed', { + requestId, passed: false, detectedCount: detectedEntities.length, }) @@ -477,7 +479,8 @@ export async function validatePII(input: PIIValidationInput): Promise = { fireflies_v2: FirefliesIcon, fivetran: FivetranIcon, flint: FlintIcon, + flow: ApiIcon, gamma: GammaIcon, + gauge: GaugeIcon, generic: WebhookIcon, github: GithubIcon, github_v2: GithubIcon, @@ -429,6 +433,7 @@ export const blockTypeToIconMap: Record = { intercom: IntercomIcon, intercom_v2: IntercomIcon, intune: IntuneIcon, + jama: ApiIcon, jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, diff --git a/apps/sim/lib/internal/a2a/execute-tool.ts b/apps/sim/lib/internal/a2a/execute-tool.ts index c7ef748e8b3..89f6ef315b8 100644 --- a/apps/sim/lib/internal/a2a/execute-tool.ts +++ b/apps/sim/lib/internal/a2a/execute-tool.ts @@ -117,7 +117,8 @@ export const executeA2ATool: InternalToolOperationHandler = async (request) => { } const notReady = docNotReadyResponse(error) if (notReady) return notReady - logger.error(`[${request.requestId}] A2A operation failed`, { + logger.error('A2A operation failed', { + requestId: request.requestId, error: getErrorMessage(error), toolId: request.toolId, }) diff --git a/apps/sim/lib/internal/a2a/operations.ts b/apps/sim/lib/internal/a2a/operations.ts index df31b497e71..1396e465b7b 100644 --- a/apps/sim/lib/internal/a2a/operations.ts +++ b/apps/sim/lib/internal/a2a/operations.ts @@ -124,12 +124,14 @@ export async function sendA2AMessage(input: A2ASendMessageInput, context: A2AOpe context.signal?.throwIfAborted() if (!isTaskResult(result)) { - logger.info(`[${context.requestId}] A2A send returned a direct message`) + logger.info('A2A send returned a direct message', { requestId: context.requestId }) return { success: true as const, output: messageOutput(result) } } const output = taskOutput(result) const errored = taskErrored(result) - logger.info(`[${context.requestId}] A2A send produced task ${result.id} (${output.state})`) + logger.info(`A2A send produced task ${result.id} (${output.state})`, { + requestId: context.requestId, + }) return { success: !errored, ...(errored ? { error: output.content || `Agent task ${output.state}` } : {}), @@ -145,7 +147,7 @@ export async function getA2ATask(input: A2AGetTaskInput, context: A2AOperationCo historyLength: input.historyLength, }) context.signal?.throwIfAborted() - logger.info(`[${context.requestId}] Retrieved A2A task ${task.id}`) + logger.info(`Retrieved A2A task ${task.id}`, { requestId: context.requestId }) return { success: true as const, output: taskOutput(task) } } @@ -154,7 +156,7 @@ export async function cancelA2ATask(input: A2ACancelTaskInput, context: A2AOpera const task = await client.cancelTask({ tenant: '', id: input.taskId, metadata: undefined }) context.signal?.throwIfAborted() const output = taskOutput(task) - logger.info(`[${context.requestId}] Cancel requested for A2A task ${task.id}`) + logger.info(`Cancel requested for A2A task ${task.id}`, { requestId: context.requestId }) return { success: true as const, output: { taskId: output.taskId, state: output.state, canceled: output.state === 'canceled' }, @@ -165,6 +167,6 @@ export async function getA2AAgentCard(input: A2AGetAgentCardInput, context: A2AO const client = await createA2AClient(input.agentUrl, input.apiKey, { signal: context.signal }) const card = await client.getAgentCard() context.signal?.throwIfAborted() - logger.info(`[${context.requestId}] Fetched agent card for ${card.name}`) + logger.info(`Fetched agent card for ${card.name}`, { requestId: context.requestId }) return { success: true as const, output: agentCardOutput(card, input.agentUrl) } } diff --git a/apps/sim/lib/internal/ashby/operations.ts b/apps/sim/lib/internal/ashby/operations.ts index 125f8289097..b0547b52309 100644 --- a/apps/sim/lib/internal/ashby/operations.ts +++ b/apps/sim/lib/internal/ashby/operations.ts @@ -161,7 +161,8 @@ export async function executeAshbyUpload( context.signal?.throwIfAborted() const notReady = docNotReadyResponse(error) if (notReady) return notReady - logger.error(`[${context.requestId}] Ashby candidate file upload failed`, { + logger.error('Ashby candidate file upload failed', { + requestId: context.requestId, error: getErrorMessage(error), }) return failure( diff --git a/apps/sim/lib/internal/buffer/operations.ts b/apps/sim/lib/internal/buffer/operations.ts index f4324b5bee4..8c9aac638cf 100644 --- a/apps/sim/lib/internal/buffer/operations.ts +++ b/apps/sim/lib/internal/buffer/operations.ts @@ -92,7 +92,8 @@ async function resolveMediaKind(args: { } } catch (error) { context.signal?.throwIfAborted() - logger.warn(`[${context.requestId}] Media content-type probe was inconclusive`, { + logger.warn('Media content-type probe was inconclusive', { + requestId: context.requestId, error: getErrorMessage(error, 'probe failed'), }) } @@ -171,7 +172,7 @@ async function executePostMutation(args: { } catch (error) { context.signal?.throwIfAborted() const message = getErrorMessage(error, 'Buffer API request failed') - logger.error(`[${context.requestId}] Buffer post mutation failed`, { error: message }) + logger.error('Buffer post mutation failed', { requestId: context.requestId, error: message }) throw new BufferOperationError(message, 502) } diff --git a/apps/sim/lib/internal/cursor/operations.ts b/apps/sim/lib/internal/cursor/operations.ts index 4ac9ba2cc81..132cb3bdbd1 100644 --- a/apps/sim/lib/internal/cursor/operations.ts +++ b/apps/sim/lib/internal/cursor/operations.ts @@ -111,7 +111,8 @@ export async function downloadCursorArtifact( mimeType: downloadResponse.headers.get('content-type') || 'application/octet-stream', buffer: fileBuffer, } - logger.info(`[${context.requestId}] Cursor artifact downloaded`, { + logger.info('Cursor artifact downloaded', { + requestId: context.requestId, agentId: input.agentId, path: input.path, size: fileBuffer.length, diff --git a/apps/sim/lib/internal/firecrawl/operations.ts b/apps/sim/lib/internal/firecrawl/operations.ts index 55a8a21efe4..3b4faa306e0 100644 --- a/apps/sim/lib/internal/firecrawl/operations.ts +++ b/apps/sim/lib/internal/firecrawl/operations.ts @@ -121,7 +121,8 @@ export async function executeFirecrawlParse( context.signal?.throwIfAborted() const notReady = docNotReadyResponse(error) if (notReady) return notReady - logger.error(`[${context.requestId}] Firecrawl parse failed`, { + logger.error('Firecrawl parse failed', { + requestId: context.requestId, error: getErrorMessage(error), }) return failureResponse( diff --git a/apps/sim/lib/internal/fireflies/operations.ts b/apps/sim/lib/internal/fireflies/operations.ts index 043a3c14a65..8b0dff6a229 100644 --- a/apps/sim/lib/internal/fireflies/operations.ts +++ b/apps/sim/lib/internal/fireflies/operations.ts @@ -111,13 +111,15 @@ export async function executeFirefliesUploadAudio( signal: context.signal, }) - logger.info(`[${context.requestId}] Fireflies upload request completed`, { + logger.info('Fireflies upload request completed', { + requestId: context.requestId, status: response.status, }) return Response.json(data, { status: response.status }) } catch (error) { if (context.signal?.aborted) throw error - logger.error(`[${context.requestId}] Fireflies upload failed`, { + logger.error('Fireflies upload failed', { + requestId: context.requestId, error: getErrorMessage(error, 'Unknown error'), }) return errorResponse('Failed to upload audio', 500) diff --git a/apps/sim/lib/internal/guardrails/execute-tool.ts b/apps/sim/lib/internal/guardrails/execute-tool.ts index 17fd2dfabe2..2d5c2114386 100644 --- a/apps/sim/lib/internal/guardrails/execute-tool.ts +++ b/apps/sim/lib/internal/guardrails/execute-tool.ts @@ -12,7 +12,7 @@ const logger = createLogger('GuardrailsToolExecution') function unexpectedFailure(error: unknown, requestId: string): Response { const message = getErrorMessage(error, 'Validation failed due to unexpected error') - logger.error(`[${requestId}] Guardrails validation failed`, { error: message }) + logger.error('Guardrails validation failed', { requestId, error: message }) return Response.json({ success: true, output: { diff --git a/apps/sim/lib/internal/guardrails/operations.ts b/apps/sim/lib/internal/guardrails/operations.ts index 64606b99e75..dc3b1c3a70b 100644 --- a/apps/sim/lib/internal/guardrails/operations.ts +++ b/apps/sim/lib/internal/guardrails/operations.ts @@ -104,7 +104,8 @@ async function authorizeVertexCredential( callerUserId: context.actorUserId, }) if (!access.ok) { - logger.warn(`[${context.requestId}] Vertex credential access denied`, { + logger.warn('Vertex credential access denied', { + requestId: context.requestId, error: access.error, credentialId: input.vertexCredential, }) @@ -146,7 +147,7 @@ async function prepareHallucinationContext( workspaceId, }) } catch (error) { - logger.error(`[${context.requestId}] Failed to establish billing attribution`, { error }) + logger.error('Failed to establish billing attribution', { requestId: context.requestId, error }) fail(400, 'Invalid billing attribution') } @@ -299,7 +300,7 @@ async function recordHallucinationUsage( }) await checkAndBillPayerOverageThreshold(hallucinationContext.billingAttribution.billingEntity) } catch (error) { - logger.error(`[${context.requestId}] Failed to record guardrail usage`, { error }) + logger.error('Failed to record guardrail usage', { requestId: context.requestId, error }) } } diff --git a/apps/sim/lib/internal/image/operations.test.ts b/apps/sim/lib/internal/image/operations.test.ts index e9b59945001..7021860ffb1 100644 --- a/apps/sim/lib/internal/image/operations.test.ts +++ b/apps/sim/lib/internal/image/operations.test.ts @@ -6,6 +6,7 @@ import { inputValidationMock, inputValidationMockFns, } from '@sim/testing/mocks/input-validation.mock' +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { uploadsCopilotMock, uploadsCopilotMockFns } from '@sim/testing/mocks/uploads-copilot.mock' import { uploadsExecutionMock } from '@sim/testing/mocks/uploads-execution.mock' import { utilsHelpersMock, utilsHelpersMockFns } from '@sim/testing/mocks/utils-helpers.mock' @@ -13,7 +14,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ fetch: vi.fn(), - getFalAICostMetadata: vi.fn(), + getFalAIEstimatedCostMetadata: vi.fn(), + getFalAIBillingReceipt: vi.fn(), })) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) @@ -23,7 +25,9 @@ vi.mock('@sim/utils/helpers', () => utilsHelpersMock) vi.mock('@/lib/core/execution-limits', () => executionLimitsMock) vi.mock('@/lib/tools/falai-pricing', () => ({ - getFalAICostMetadata: mocks.getFalAICostMetadata, + FALAI_COST_LOOKUP_TIMEOUT_MS: 10_000, + getFalAIEstimatedCostMetadata: mocks.getFalAIEstimatedCostMetadata, + getFalAIBillingReceipt: mocks.getFalAIBillingReceipt, })) vi.mock('@/lib/uploads/contexts/copilot', () => uploadsCopilotMock) @@ -33,6 +37,7 @@ vi.mock('@/lib/uploads/contexts/execution', () => uploadsExecutionMock) const { mockSecureFetchWithPinnedIP, mockValidateUrlWithDNS } = inputValidationMockFns import { executeImageGeneration } from '@/lib/internal/image/operations' +import { observeServiceCosts } from '@/lib/mothership/billing/service-observer' const { mockInterruptibleSleep } = utilsHelpersMockFns @@ -50,6 +55,8 @@ const falInput = { describe('image operations', () => { beforeEach(() => { mocks.fetch.mockReset() + mocks.getFalAIEstimatedCostMetadata.mockReset() + mocks.getFalAIBillingReceipt.mockReset().mockResolvedValue(undefined) mockSecureFetchWithPinnedIP.mockReset() vi.stubGlobal('fetch', mocks.fetch) mockInterruptibleSleep.mockResolvedValue(undefined) @@ -182,15 +189,17 @@ describe('image operations', () => { it('retains the completed fal receipt when delivery is interrupted', async () => { const controller = new AbortController() mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'paid-job' })) - mocks.getFalAICostMetadata.mockImplementation(async ({ signal }: { signal?: AbortSignal }) => { - signal?.throwIfAborted() - return { - requestId: 'paid-job', - endpointId: 'fal-ai/nano-banana-2', - costDollars: 0.1, - source: 'billing_events', + mocks.getFalAIBillingReceipt.mockImplementation( + async ({ signal }: { signal?: AbortSignal }) => { + signal?.throwIfAborted() + return { + requestId: 'paid-job', + endpointId: 'fal-ai/nano-banana-2', + costDollars: 0.1, + source: 'billing_events', + } } - }) + ) mockSecureFetchWithPinnedIP.mockResolvedValueOnce(Response.json({ status: 'COMPLETED' })) mockSecureFetchWithPinnedIP.mockImplementationOnce(async () => { controller.abort() @@ -207,9 +216,130 @@ describe('image operations', () => { } ) ).rejects.toMatchObject({ name: 'AbortError' }) - expect(mocks.getFalAICostMetadata).toHaveBeenCalledWith( + expect(mocks.getFalAIBillingReceipt).toHaveBeenCalledWith( expect.objectContaining({ requestId: 'paid-job' }) ) - expect(mocks.getFalAICostMetadata.mock.calls[0][0].signal).toBeUndefined() + expect(mocks.getFalAIBillingReceipt.mock.calls[0][0].signal).toBeUndefined() + }) + it.each([422, 500])( + 'reports HTTP %i generation failures without speculative delivery receipts', + async (status) => { + mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'rejected-job' })) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(Response.json({ status: 'COMPLETED' })) + .mockResolvedValueOnce( + Response.json( + { + detail: [ + { type: 'no_media_generated', msg: 'secret-prompt', input: 'secret-prompt' }, + ], + }, + { status } + ) + ) + mocks.getFalAIEstimatedCostMetadata.mockResolvedValue({ + requestId: 'rejected-job', + source: 'fallback_floor', + costDollars: 0.05, + }) + const receipts: string[] = [] + const response = await observeServiceCosts( + async () => {}, + () => + executeImageGeneration( + { ...falInput, useHostedCostTracking: true }, + { userId: 'user', requestId: 'rejected' } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + expect(response.status).toBe(status === 422 ? 422 : 502) + const body = await response.json() + expect(body.error).toContain('no_media_generated') + expect(JSON.stringify(body)).not.toContain('secret-prompt') + expect(JSON.stringify(getMockLogger('ImageOperations').error.mock.calls)).not.toContain( + 'secret-prompt' + ) + expect(receipts).toEqual([]) + expect(mocks.getFalAIEstimatedCostMetadata).not.toHaveBeenCalled() + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + } + ) + + it.each([ + { bytes: 32 * 1024 * 1024, declared: true, expected: 200 }, + { bytes: 101 * 1024 * 1024, declared: true, expected: 413 }, + { bytes: 101 * 1024 * 1024, declared: false, expected: 413 }, + ])( + 'bounds remote generated images ($bytes bytes, declared=$declared)', + async ({ bytes, declared, expected }) => { + mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'large-image' })) + let remaining = bytes + const chunk = new Uint8Array(1024 * 1024) + const binary = new Response( + new ReadableStream({ + pull(controller) { + if (remaining <= 0) { + controller.close() + return + } + controller.enqueue(chunk) + remaining -= chunk.length + }, + }), + { + headers: { + 'Content-Type': 'image/png', + ...(declared ? { 'Content-Length': String(bytes) } : {}), + }, + } + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(Response.json({ status: 'COMPLETED' })) + .mockResolvedValueOnce( + Response.json({ images: [{ url: 'https://cdn.example/image.png' }] }) + ) + .mockResolvedValueOnce(binary) + const response = await executeImageGeneration(falInput, { + userId: 'user', + requestId: 'large', + }) + expect(response.status).toBe(expected) + if (expected === 200) { + expect(mockUploadCopilotFile.mock.calls[0][0].buffer.byteLength).toBe(bytes) + } else { + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + } + } + ) + it('reconciles exact image receipts when a completed queue status contains an error', async () => { + mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'image-queue-error' })) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + Response.json({ status: 'COMPLETED', error: 'sensitive provider error' }) + ) + mocks.getFalAIBillingReceipt.mockResolvedValueOnce({ + requestId: 'image-queue-error', + endpointId: 'fal-ai/nano-banana-2', + source: 'billing_events', + costDollars: 0.1, + }) + const receipts: string[] = [] + const response = await observeServiceCosts( + async () => {}, + () => + executeImageGeneration( + { ...falInput, useHostedCostTracking: true }, + { userId: 'user', requestId: 'queue-error' } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + expect(response.ok).toBe(false) + expect(JSON.stringify(await response.json())).not.toContain('sensitive provider error') + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain('0.1 (billing_events)') + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/internal/image/operations.ts b/apps/sim/lib/internal/image/operations.ts index 0feef97bf1f..bc505761e00 100644 --- a/apps/sim/lib/internal/image/operations.ts +++ b/apps/sim/lib/internal/image/operations.ts @@ -19,10 +19,12 @@ import { import { MAX_REMOTE_IMAGE_BYTES } from '@/lib/internal/image/fetch' import { buildImageGenerationRequest } from '@/lib/internal/image/request' import type { ImageGenerationInput, ImageProvider } from '@/lib/internal/image/schema' +import { FalAIResultError, fetchFalAIResult } from '@/lib/media/falai-result' import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-observer' -import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' +import type { FalAICostMetadata } from '@/lib/tools/falai-pricing' import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { resolveStoredFileMetadata } from '@/lib/uploads/utils/stored-file-metadata' const logger = createLogger('ImageOperations') @@ -78,8 +80,8 @@ export async function executeImageGeneration( context: ImageOperationContext ): Promise { const { requestId } = context - let completedFalCost: FalAICostMetadata | undefined - logger.info(`[${requestId}] Image generation request started`) + let undeliveredFalCost: FalAICostMetadata | undefined + logger.info('Image generation request started', { requestId }) try { context.signal?.throwIfAborted() @@ -93,7 +95,7 @@ export async function executeImageGeneration( return Response.json({ error: getErrorMessage(error) }, { status: 400 }) } - logger.info(`[${requestId}] Generating image with ${provider}, model: ${model || 'default'}`) + logger.info(`Generating image with ${provider}, model: ${model || 'default'}`, { requestId }) let imageResult: GeneratedImageResult if (provider === 'openai') { @@ -108,7 +110,7 @@ export async function executeImageGeneration( requestId, logger, (cost) => { - completedFalCost = cost + undeliveredFalCost = cost }, context.signal ) @@ -118,7 +120,8 @@ export async function executeImageGeneration( const storedImage = await storeGeneratedImage(imageResult, context) - logger.info(`[${requestId}] Image generation completed successfully`, { + logger.info('Image generation completed successfully', { + requestId, provider, model: storedImage.model, contentType: storedImage.contentType, @@ -126,27 +129,34 @@ export async function executeImageGeneration( return Response.json(storedImage) } catch (error) { - if (completedFalCost) { - logger.warn('Hosted fal image completed but delivery failed', { + if (undeliveredFalCost) { + logger.warn('Hosted fal image result delivery interrupted', { requestId, - falaiBilling: completedFalCost, + falaiBilling: undeliveredFalCost, }) - const { requestId: jobId, endpointId, costDollars } = completedFalCost + const { requestId: jobId, endpointId, costDollars } = undeliveredFalCost await recordServiceMeteringFailure( - `Completed fal image ${jobId} (${endpointId}): USD ${costDollars}; delivery failed` + `Undelivered fal image ${jobId} (${endpointId}): USD ${costDollars} (${undeliveredFalCost.source}); reconciliation required` ).catch((meteringError) => { - logger.error('Failed to record completed fal image for reconciliation', { + logger.error('Failed to record undelivered fal image for reconciliation', { requestId, error: getErrorMessage(meteringError), }) }) } context.signal?.throwIfAborted() - logger.error(`[${requestId}] Image generation operation error:`, error) + logger.error('Image generation operation error:', error, { requestId }) const errorMessage = getUserFacingErrorMessage(error, 'Unknown error') return Response.json( { error: errorMessage }, - { status: isPayloadSizeLimitError(error) ? 413 : 500 } + { + status: + error instanceof FalAIResultError + ? error.status + : isPayloadSizeLimitError(error) + ? 413 + : 500, + } ) } } @@ -207,7 +217,7 @@ async function bufferFromImageUrl( const imageResponse = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { profile: 'contentFetch', method: 'GET', - maxResponseBytes: MAX_IMAGE_BYTES, + maxResponseBytes: MAX_BUFFERED_TRANSFER_BYTES, signal, }) if (!imageResponse.ok) { @@ -221,7 +231,7 @@ async function bufferFromImageUrl( const contentType = imageResponse.headers.get('content-type') || 'image/png' const buffer = await readResponseToBufferWithLimit(imageResponse, { - maxBytes: MAX_IMAGE_BYTES, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, label: 'generated image download', signal, }) @@ -282,7 +292,7 @@ async function generateWithOpenAI( buffer = downloaded.buffer contentType = downloaded.contentType } else { - logger.error(`[${requestId}] OpenAI response missing image payload`) + logger.error('OpenAI response missing image payload', { requestId }) throw new Error('No image data found in OpenAI response') } @@ -344,7 +354,7 @@ async function generateWithGemini( }) if (!isRecordLike(imagePart)) { - logger.error(`[${requestId}] Gemini response missing image part`) + logger.error('Gemini response missing image part', { requestId }) throw new Error('No image data found in Gemini response') } @@ -455,7 +465,7 @@ async function generateWithFalAI( buildFalAIQueueUrl(endpoint, falRequestId, 'response') ) - logger.info(`[${requestId}] Fal.ai image request created: ${falRequestId}`) + logger.info('Fal.ai image request created', { requestId, falRequestId }) const statusValidation = await validateUrlWithDNS(statusUrl, 'statusUrl', 'contentFetch') if (!statusValidation.isValid) { @@ -500,46 +510,31 @@ async function generateWithFalAI( const status = getStringProperty(statusData, 'status') if (status === 'COMPLETED') { - const statusError = statusData.error - if (statusError) { - throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) - } - - const falaiCost = body.useHostedCostTracking - ? await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId: falRequestId }) - : undefined - if (falaiCost) onFalCost(falaiCost) - signal?.throwIfAborted() - - const resultUrl = falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl) - const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch') - if (!resultValidation.isValid) { - throw new Error(resultValidation.error) - } - const resultResponse = await secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, { - profile: 'contentFetch', - maxRedirects: 0, - headers: { - Authorization: `Key ${apiKey}`, - }, + const { data: resultData, cost: falaiCost } = await fetchFalAIResult({ + apiKey, + endpointId: endpoint, + requestId: falRequestId, + queueError: statusData.error, maxResponseBytes: MAX_IMAGE_JSON_BYTES, + trackCost: body.useHostedCostTracking, + onCost: onFalCost, signal, + fetchResponse: async (resultSignal) => { + const resultUrl = falQueueUrl( + getStringProperty(statusData, 'response_url') || responseUrl + ) + const resultValidation = await validateUrlWithDNS(resultUrl, 'resultUrl', 'contentFetch') + if (!resultValidation.isValid) throw new Error(resultValidation.error) + return secureFetchWithPinnedIP(resultUrl, resultValidation.resolvedIP, { + profile: 'contentFetch', + maxRedirects: 0, + headers: { Authorization: `Key ${apiKey}` }, + maxResponseBytes: MAX_IMAGE_JSON_BYTES, + signal: resultSignal, + }) + }, }) - if (!resultResponse.ok) { - await readResponseTextWithLimit(resultResponse, { - maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, - label: 'Fal.ai result error response', - signal, - }).catch(() => '') - throw new Error(`Failed to fetch Fal.ai result: ${resultResponse.status}`) - } - - const resultData = await readResponseJsonWithLimit(resultResponse, { - maxBytes: MAX_IMAGE_JSON_BYTES, - label: 'Fal.ai result response', - signal, - }) if (!isRecordLike(resultData)) { throw new Error('Invalid Fal.ai result response') } diff --git a/apps/sim/lib/internal/jupyter/execute-tool.ts b/apps/sim/lib/internal/jupyter/execute-tool.ts index d732610cb30..2d29edaaa9b 100644 --- a/apps/sim/lib/internal/jupyter/execute-tool.ts +++ b/apps/sim/lib/internal/jupyter/execute-tool.ts @@ -55,7 +55,7 @@ function unexpectedErrorResponse( ): Response { signal?.throwIfAborted() const logger = scope === 'proxy' ? proxyLogger : uploadLogger - logger.error(`[${requestId}] Unexpected error:`, error) + logger.error('Unexpected error:', error, { requestId }) return Response.json( { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } diff --git a/apps/sim/lib/internal/jupyter/operations.ts b/apps/sim/lib/internal/jupyter/operations.ts index 9fb6791e083..c3875ce525c 100644 --- a/apps/sim/lib/internal/jupyter/operations.ts +++ b/apps/sim/lib/internal/jupyter/operations.ts @@ -230,10 +230,7 @@ export async function executeJupyterUpload( if (!response.ok) { const errorText = await response.text() signal?.throwIfAborted() - uploadLogger.error(`[${requestId}] Jupyter API error:`, { - status: response.status, - errorText, - }) + uploadLogger.error('Jupyter API error:', { requestId, status: response.status, errorText }) return Response.json( { success: false, error: `Jupyter API error: ${response.status} ${errorText}` }, { status: response.status } @@ -244,7 +241,7 @@ export async function executeJupyterUpload( signal?.throwIfAborted() const uploaded = parseJupyterContentModel(uploadedValue) if (!uploaded) { - uploadLogger.error(`[${requestId}] Jupyter returned an invalid upload response`) + uploadLogger.error('Jupyter returned an invalid upload response', { requestId }) return Response.json( { success: false, error: 'Jupyter returned an invalid upload response' }, { status: 502 } @@ -256,7 +253,7 @@ export async function executeJupyterUpload( const uploadedSize = uploaded.size ?? file.buffer.length const lastModified = uploaded.lastModified ?? null - uploadLogger.info(`[${requestId}] File uploaded to Jupyter: ${uploadedPath}`) + uploadLogger.info(`File uploaded to Jupyter: ${uploadedPath}`, { requestId }) return Response.json({ success: true, output: { diff --git a/apps/sim/lib/internal/knowledge/execute-tool.ts b/apps/sim/lib/internal/knowledge/execute-tool.ts index 535a0e2beb1..2a2e3a6170c 100644 --- a/apps/sim/lib/internal/knowledge/execute-tool.ts +++ b/apps/sim/lib/internal/knowledge/execute-tool.ts @@ -94,7 +94,7 @@ function projectError( signal?.throwIfAborted() const projected = policy.project(error) if (projected && projected.status < 500) return descriptorResponse(projected) - logger.error(`[${requestId}] Knowledge tool execution failed`, { error }) + logger.error('Knowledge tool execution failed', { requestId, error }) if (projected) return descriptorResponse(projected) return descriptorResponse( policy.unhandled?.() ?? { status: 500, body: { error: 'Internal server error' } } diff --git a/apps/sim/lib/internal/llm/credentials.ts b/apps/sim/lib/internal/llm/credentials.ts index 2dbd6407e9a..4c450190600 100644 --- a/apps/sim/lib/internal/llm/credentials.ts +++ b/apps/sim/lib/internal/llm/credentials.ts @@ -15,7 +15,7 @@ export async function resolveVertexAccessToken( requestId: string, credentialId: string ): Promise { - logger.info(`[${requestId}] Resolving Vertex AI credential`, { credentialId }) + logger.info('Resolving Vertex AI credential', { requestId, credentialId }) const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) throw new Error(`Vertex AI credential not found: ${credentialId}`) @@ -24,7 +24,7 @@ export async function resolveVertexAccessToken( const accessToken = await getServiceAccountToken(resolved.credentialId, [ 'https://www.googleapis.com/auth/cloud-platform', ]) - logger.info(`[${requestId}] Resolved Vertex AI service account credential`) + logger.info('Resolved Vertex AI service account credential', { requestId }) return accessToken } @@ -36,6 +36,6 @@ export async function resolveVertexAccessToken( const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) if (!accessToken) throw new Error('Failed to get Vertex AI access token') - logger.info(`[${requestId}] Resolved Vertex AI credential`) + logger.info('Resolved Vertex AI credential', { requestId }) return accessToken } diff --git a/apps/sim/lib/internal/llm/execute-tool.ts b/apps/sim/lib/internal/llm/execute-tool.ts index 4f42bfad539..17307d0c79b 100644 --- a/apps/sim/lib/internal/llm/execute-tool.ts +++ b/apps/sim/lib/internal/llm/execute-tool.ts @@ -67,7 +67,7 @@ export const executeLlmTool: InternalToolOperationHandler = async (request) => { return Response.json(error.body, { status: error.status }) } const message = getErrorMessage(error) - logger.error(`[${request.requestId}] LLM chat operation failed`, { error: message }) + logger.error('LLM chat operation failed', { requestId: request.requestId, error: message }) return Response.json({ error: message }, { status: 500 }) } } diff --git a/apps/sim/lib/internal/llm/operations.ts b/apps/sim/lib/internal/llm/operations.ts index c0ee22c836a..c34f1e2ec1f 100644 --- a/apps/sim/lib/internal/llm/operations.ts +++ b/apps/sim/lib/internal/llm/operations.ts @@ -63,7 +63,8 @@ async function authorizeVertexCredential( callerUserId: context.actorUserId, }) if (!access.ok) { - logger.warn(`[${context.requestId}] Vertex credential access denied`, { + logger.warn('Vertex credential access denied', { + requestId: context.requestId, error: access.error, credentialId: input.vertexCredential, }) @@ -130,7 +131,8 @@ async function prepareProviderRequest( } } catch (error) { if (error instanceof LlmOperationError) throw error - logger.error(`[${context.requestId}] Failed to resolve Vertex credential`, { + logger.error('Failed to resolve Vertex credential', { + requestId: context.requestId, provider: input.provider, model: input.model, error: toError(error).message, diff --git a/apps/sim/lib/internal/sap-concur/client.ts b/apps/sim/lib/internal/sap-concur/client.ts index 8a67bb75eab..9d3e6959a5e 100644 --- a/apps/sim/lib/internal/sap-concur/client.ts +++ b/apps/sim/lib/internal/sap-concur/client.ts @@ -329,10 +329,11 @@ async function requestAccessToken( const text = redactTokenSecrets(await response.text().catch(() => ''), auth) signal?.throwIfAborted() logger.warn( - `[${requestId}] Concur token fetch failed (${response.status}): ${truncate( + `Concur token fetch failed (${response.status}): ${truncate( text, TOKEN_ERROR_RAW_BODY_MAX_LENGTH - )}` + )}`, + { requestId } ) throw new Error( `Concur token request failed: ${extractSapConcurError(parseMaybeJson(text), response.status, { diff --git a/apps/sim/lib/internal/sap-s4hana/operations.ts b/apps/sim/lib/internal/sap-s4hana/operations.ts index 3b00205095d..1c93e7b1cf1 100644 --- a/apps/sim/lib/internal/sap-s4hana/operations.ts +++ b/apps/sim/lib/internal/sap-s4hana/operations.ts @@ -100,7 +100,7 @@ export async function executeSapS4HanaOperation( let invocation = await callSapOdata(input, accessToken, csrf, signal) if (isWrite && isCsrfRequired(invocation)) { - logger.info(`[${requestId}] CSRF token rejected, refetching once`) + logger.info('CSRF token rejected, refetching once', { requestId }) const refreshed = await fetchSapCsrf(input, accessToken, signal) if (refreshed) { invocation = await callSapOdata(input, accessToken, refreshed, signal) @@ -116,8 +116,8 @@ export async function executeSapS4HanaOperation( } const message = extractOdataError(invocation.body, invocation.status) - logger.warn( - `[${requestId}] SAP API error (${invocation.status}) ${input.service}${input.path}: ${message}` - ) + logger.warn(`SAP API error (${invocation.status}) ${input.service}${input.path}: ${message}`, { + requestId, + }) throw new SapS4HanaProviderError(message, invocation.status) } diff --git a/apps/sim/lib/internal/slack/operations.ts b/apps/sim/lib/internal/slack/operations.ts index 8328b54301d..a275e8da08e 100644 --- a/apps/sim/lib/internal/slack/operations.ts +++ b/apps/sim/lib/internal/slack/operations.ts @@ -320,7 +320,8 @@ async function uploadSlackFiles( const uploadUrl = slackString(data, 'upload_url') const fileId = slackString(data, 'file_id') if (!slackOk(data) || !uploadUrl || !fileId) { - logger.error(`[${context.requestId}] Failed to get Slack upload URL`, { + logger.error('Failed to get Slack upload URL', { + requestId: context.requestId, error: slackString(data, 'error'), }) return @@ -338,7 +339,8 @@ async function uploadSlackFiles( ) context.signal?.throwIfAborted() if (!uploaded.ok) { - logger.error(`[${context.requestId}] Failed to upload Slack file data`, { + logger.error('Failed to upload Slack file data', { + requestId: context.requestId, status: uploaded.status, }) return diff --git a/apps/sim/lib/internal/square/operations.ts b/apps/sim/lib/internal/square/operations.ts index 14dcd670971..13439d0830d 100644 --- a/apps/sim/lib/internal/square/operations.ts +++ b/apps/sim/lib/internal/square/operations.ts @@ -119,7 +119,8 @@ export async function executeSquareCreateCatalogImage( }) } catch (error) { context.signal?.throwIfAborted() - logger.error(`[${context.requestId}] Square catalog image upload failed`, { + logger.error('Square catalog image upload failed', { + requestId: context.requestId, error: getErrorMessage(error), }) return failureResponse(getErrorMessage(error, 'Unknown error'), 500) diff --git a/apps/sim/lib/internal/stt/execute-tool.ts b/apps/sim/lib/internal/stt/execute-tool.ts index 1648acb5c18..f45779fad76 100644 --- a/apps/sim/lib/internal/stt/execute-tool.ts +++ b/apps/sim/lib/internal/stt/execute-tool.ts @@ -49,7 +49,10 @@ export const executeSttTool: InternalToolOperationHandler = async (request) => { const parsed = sttOperationInputSchema.safeParse(request.input) if (!parsed.success) { - logger.warn(`[${request.requestId}] Invalid STT request`, { issues: parsed.error.issues }) + logger.warn('Invalid STT request', { + requestId: request.requestId, + issues: parsed.error.issues, + }) return Response.json( { error: 'Invalid request data', details: parsed.error.issues }, { status: 400 } diff --git a/apps/sim/lib/internal/stt/operations.ts b/apps/sim/lib/internal/stt/operations.ts index e9ffb148a10..a68c15750ea 100644 --- a/apps/sim/lib/internal/stt/operations.ts +++ b/apps/sim/lib/internal/stt/operations.ts @@ -143,7 +143,7 @@ export async function executeSttOperation( ): Promise { const { headers, requestId, signal, userId } = context signal?.throwIfAborted() - logger.info(`[${requestId}] STT transcription request started`) + logger.info('STT transcription request started', { requestId }) try { const modelInputProvenance = validateOpaqueModelInputProvenance({ @@ -181,7 +181,7 @@ export async function executeSttOperation( return Response.json({ error: 'audioFile must be a single file' }, { status: 400 }) } const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile - logger.info(`[${requestId}] Processing uploaded audio`) + logger.info('Processing uploaded audio', { requestId }) const deniedAudio = await assertToolFileAccess(file.key, userId, requestId, logger) if (deniedAudio) return deniedAudio @@ -202,7 +202,7 @@ export async function executeSttOperation( const file = Array.isArray(body.audioFileReference) ? body.audioFileReference[0] : body.audioFileReference - logger.info(`[${requestId}] Processing referenced audio`) + logger.info('Processing referenced audio', { requestId }) const deniedRef = await assertToolFileAccess(file.key, userId, requestId, logger) if (deniedRef) return deniedRef @@ -220,7 +220,7 @@ export async function executeSttOperation( } else if (body.audioUrl) { let audioUrl = body.audioUrl.trim() const internalAudioUrl = isInternalFileUrl(audioUrl) - logger.info(`[${requestId}] Downloading audio source`, { internal: internalAudioUrl }) + logger.info('Downloading audio source', { requestId, internal: internalAudioUrl }) if (audioUrl.startsWith('/') && !isInternalFileUrl(audioUrl)) { return Response.json( { @@ -287,7 +287,7 @@ export async function executeSttOperation( } if (isVideoFile(audioMimeType)) { - logger.info(`[${requestId}] Extracting audio from video file`) + logger.info('Extracting audio from video file', { requestId }) try { const extracted = await extractAudioFromVideo(audioBuffer, audioMimeType, { outputFormat: 'mp3', @@ -301,7 +301,7 @@ export async function executeSttOperation( audioFileName = audioFileName.replace(/\.[^.]+$/, '.mp3') } catch (error) { signal?.throwIfAborted() - logger.error(`[${requestId}] Video extraction failed:`, error) + logger.error('Video extraction failed:', error, { requestId }) if (isPayloadSizeLimitError(error)) { return Response.json( { error: 'Extracted audio exceeds the maximum supported size' }, @@ -317,7 +317,7 @@ export async function executeSttOperation( } } - logger.info(`[${requestId}] Transcribing audio`, { provider }) + logger.info('Transcribing audio', { requestId, provider }) let transcript: string let segments: TranscriptSegment[] | undefined @@ -417,12 +417,12 @@ export async function executeSttOperation( } } catch (error) { signal?.throwIfAborted() - logger.error(`[${requestId}] Transcription failed:`, error) + logger.error('Transcription failed:', error, { requestId }) const errorMessage = getErrorMessage(error, 'Transcription failed') return Response.json({ error: errorMessage }, { status: 500 }) } - logger.info(`[${requestId}] Transcription completed successfully`) + logger.info('Transcription completed successfully', { requestId }) const response: SttOutput = { transcript } if (segments !== undefined) response.segments = segments @@ -436,7 +436,7 @@ export async function executeSttOperation( return Response.json(response) } catch (error) { signal?.throwIfAborted() - logger.error(`[${requestId}] STT proxy error:`, error) + logger.error('STT proxy error:', error, { requestId }) const isSizeLimit = isPayloadSizeLimitError(error) const errorMessage = isSizeLimit ? 'Audio file exceeds the maximum supported size' diff --git a/apps/sim/lib/internal/table/execute-tool.ts b/apps/sim/lib/internal/table/execute-tool.ts index d0776a23fde..19d840dc455 100644 --- a/apps/sim/lib/internal/table/execute-tool.ts +++ b/apps/sim/lib/internal/table/execute-tool.ts @@ -113,7 +113,7 @@ function errorResponse( headers: projected.headers, }) } - logger.error(`[${requestId}] ${FAILURE_MESSAGES[toolId] ?? 'Table operation failed'}:`, error) + logger.error(`${FAILURE_MESSAGES[toolId] ?? 'Table operation failed'}:`, error, { requestId }) return Response.json( { error: FAILURE_MESSAGES[toolId] ?? 'Table operation failed' }, { status: 500 } diff --git a/apps/sim/lib/internal/textract/document-input.ts b/apps/sim/lib/internal/textract/document-input.ts index bd871fb5923..0c4cc6d32ec 100644 --- a/apps/sim/lib/internal/textract/document-input.ts +++ b/apps/sim/lib/internal/textract/document-input.ts @@ -153,10 +153,7 @@ export async function resolveDocumentInput( } } } else if (fileUrl.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: fileUrl.substring(0, 50), - }) + logger.warn('Invalid internal path', { requestId, userId, path: fileUrl.substring(0, 50) }) return { ok: false, response: NextResponse.json( @@ -170,7 +167,8 @@ export async function resolveDocumentInput( } else { const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL', 'contentFetch') if (!urlValidation.isValid) { - logger.warn(`[${requestId}] SSRF attempt blocked`, { + logger.warn('SSRF attempt blocked', { + requestId, userId, url: fileUrl.substring(0, 100), error: urlValidation.error, diff --git a/apps/sim/lib/internal/textract/errors.ts b/apps/sim/lib/internal/textract/errors.ts index 44d19f7898d..b1a0e25e307 100644 --- a/apps/sim/lib/internal/textract/errors.ts +++ b/apps/sim/lib/internal/textract/errors.ts @@ -23,7 +23,7 @@ export function textractErrorResponse( const notReady = docNotReadyResponse(error) if (notReady) return notReady - logger.error(`[${requestId}] Error in Textract request:`, error) + logger.error('Error in Textract request:', error, { requestId }) const status = error instanceof TextractOperationError ? error.status : 500 return NextResponse.json( { success: false, error: getErrorMessage(error, 'Internal server error') }, diff --git a/apps/sim/lib/internal/textract/operations.ts b/apps/sim/lib/internal/textract/operations.ts index 7a021191c60..84b20566792 100644 --- a/apps/sim/lib/internal/textract/operations.ts +++ b/apps/sim/lib/internal/textract/operations.ts @@ -114,7 +114,8 @@ export async function executeTextractParse( } : undefined - parseLogger.info(`[${requestId}] Textract parse request`, { + parseLogger.info('Textract parse request', { + requestId, processingMode, hasFile: Boolean(input.file), hasS3Uri: Boolean(input.s3Uri), @@ -134,10 +135,7 @@ export async function executeTextractParse( } const { bucket, key } = parseS3Uri(input.s3Uri) - parseLogger.info(`[${requestId}] Starting async Textract job`, { - s3Bucket: bucket, - s3Key: key, - }) + parseLogger.info('Starting async Textract job', { requestId, s3Bucket: bucket, s3Key: key }) const client = createTextractClient(input) try { const { JobId: jobId } = useAnalyzeDocument @@ -156,7 +154,7 @@ export async function executeTextractParse( { abortSignal: signal } ) if (!jobId) throw new Error('Failed to start Textract job: No JobId returned') - parseLogger.info(`[${requestId}] Async job started`, { jobId }) + parseLogger.info('Async job started', { requestId, jobId }) const result = await pollTextractJob( requestId, @@ -179,7 +177,8 @@ export async function executeTextractParse( signal ) - parseLogger.info(`[${requestId}] Textract async parse successful`, { + parseLogger.info('Textract async parse successful', { + requestId, pageCount: result.DocumentMetadata?.Pages ?? 0, blockCount: result.Blocks?.length ?? 0, }) @@ -228,7 +227,8 @@ export async function executeTextractParse( throw mapTextractSdkError(error, resolved.document.isPdf) } - parseLogger.info(`[${requestId}] Textract parse successful`, { + parseLogger.info('Textract parse successful', { + requestId, pageCount: result.DocumentMetadata?.Pages ?? 0, blockCount: result.Blocks?.length ?? 0, }) @@ -259,7 +259,8 @@ export async function executeTextractAnalyzeExpense( if (provenanceError) return provenanceError const processingMode = input.processingMode || 'sync' - expenseLogger.info(`[${requestId}] Textract analyze-expense request`, { + expenseLogger.info('Textract analyze-expense request', { + requestId, processingMode, hasFile: Boolean(input.file), hasS3Uri: Boolean(input.s3Uri), @@ -277,7 +278,8 @@ export async function executeTextractAnalyzeExpense( ) } const { bucket, key } = parseS3Uri(input.s3Uri) - expenseLogger.info(`[${requestId}] Starting async Textract expense analysis job`, { + expenseLogger.info('Starting async Textract expense analysis job', { + requestId, s3Bucket: bucket, s3Key: key, }) @@ -292,7 +294,7 @@ export async function executeTextractAnalyzeExpense( if (!jobId) { throw new Error('Failed to start Textract expense analysis job: No JobId returned') } - expenseLogger.info(`[${requestId}] Async expense analysis job started`, { jobId }) + expenseLogger.info('Async expense analysis job started', { requestId, jobId }) const result = await pollTextractJob( requestId, @@ -347,7 +349,8 @@ export async function executeTextractAnalyzeExpense( throw mapTextractSdkError(error, resolved.document.isPdf) } - expenseLogger.info(`[${requestId}] Textract analyze-expense successful`, { + expenseLogger.info('Textract analyze-expense successful', { + requestId, pageCount: result.DocumentMetadata?.Pages ?? 0, expenseDocumentCount: result.ExpenseDocuments?.length ?? 0, }) @@ -376,7 +379,8 @@ export async function executeTextractAnalyzeId( const provenanceError = validateModelInputProvenance(input, headers) if (provenanceError) return provenanceError - identityLogger.info(`[${requestId}] Textract analyze-id request`, { + identityLogger.info('Textract analyze-id request', { + requestId, hasFile: Boolean(input.file), hasBackFile: Boolean(input.fileBack || input.filePathBack), userId, @@ -422,7 +426,8 @@ export async function executeTextractAnalyzeId( throw mapTextractSdkError(error, isPdf, { hasAsyncMode: false }) } - identityLogger.info(`[${requestId}] Textract analyze-id successful`, { + identityLogger.info('Textract analyze-id successful', { + requestId, pageCount: result.DocumentMetadata?.Pages ?? 0, documentCount: result.IdentityDocuments?.length ?? 0, }) diff --git a/apps/sim/lib/internal/textract/poll-job.ts b/apps/sim/lib/internal/textract/poll-job.ts index 06dcc48be84..c3c0fdee0ae 100644 --- a/apps/sim/lib/internal/textract/poll-job.ts +++ b/apps/sim/lib/internal/textract/poll-job.ts @@ -29,9 +29,9 @@ export async function pollTextractJob( if (jobStatus === 'SUCCEEDED' || jobStatus === 'PARTIAL_SUCCESS') { if (jobStatus === 'PARTIAL_SUCCESS') { - logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) + logger.warn(`Job completed with partial success: ${result.StatusMessage}`, { requestId }) } else { - logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) + logger.info(`Async job completed successfully after ${attempt + 1} polls`, { requestId }) } let merged = result @@ -52,7 +52,7 @@ export async function pollTextractJob( ) } - logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) + logger.info(`Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`, { requestId }) await sleep(pollIntervalMs) signal?.throwIfAborted() } diff --git a/apps/sim/lib/internal/tiktok/operations.ts b/apps/sim/lib/internal/tiktok/operations.ts index a02fdec061a..0ecdd7eb1a1 100644 --- a/apps/sim/lib/internal/tiktok/operations.ts +++ b/apps/sim/lib/internal/tiktok/operations.ts @@ -136,7 +136,8 @@ export async function executeTikTokUploadVideoDraft( return failureResponse(`Video exceeds the ${maxMb}MB limit for file uploads.`, 413) } if (signal.aborted) return failureResponse('TikTok video upload was cancelled.', 499) - logger.error(`[${context.requestId}] TikTok video draft upload failed`, { + logger.error('TikTok video draft upload failed', { + requestId: context.requestId, error: getErrorMessage(error), }) return failureResponse(getErrorMessage(error, 'Internal server error'), 500) diff --git a/apps/sim/lib/internal/tiktok/upload.ts b/apps/sim/lib/internal/tiktok/upload.ts index 7ec6d5687fc..4fc1c72cb23 100644 --- a/apps/sim/lib/internal/tiktok/upload.ts +++ b/apps/sim/lib/internal/tiktok/upload.ts @@ -256,7 +256,8 @@ async function uploadChunk(options: { const retryable = response.status >= 500 && response.status <= 599 if (retryable && attempt < MAX_UPLOAD_ATTEMPTS) { - logger.warn(`[${options.requestId}] Retrying TikTok video chunk`, { + logger.warn('Retrying TikTok video chunk', { + requestId: options.requestId, chunkIndex: options.chunkIndex, attempt, status: response.status, diff --git a/apps/sim/lib/internal/video/client.test.ts b/apps/sim/lib/internal/video/client.test.ts index 055a7a547dd..5947d6f329e 100644 --- a/apps/sim/lib/internal/video/client.test.ts +++ b/apps/sim/lib/internal/video/client.test.ts @@ -517,6 +517,252 @@ describe('Video provider client', () => { ) }) + it.each([422, 500])( + 'does not reconcile a failed fal result (HTTP %i) as delivered media', + async (status) => { + const paths: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + paths.push(path) + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.includes('/requests/')) + return jsonResponse( + { + detail: [ + { type: 'no_media_generated', msg: 'secret-prompt', input: 'secret-prompt' }, + ], + }, + status + ) + if (path.endsWith('/billing-events')) return jsonResponse({ billing_events: [] }) + if (path.endsWith('/estimate')) return jsonResponse({ total_cost: 0.25 }) + return jsonResponse({ request_id: 'failed-result' }) + }) + const pending: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'key', + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + pending.push(receipt) + } + ) + const rejection = expect(result).rejects.toMatchObject({ + status: status === 422 ? 422 : 502, + message: expect.stringContaining('no_media_generated'), + }) + await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)]) + expect(pending).toEqual([]) + expect(paths.some((path) => path.endsWith('/estimate'))).toBe(false) + } + ) + + it.each([ + { status: 429, body: 'Rate limit exceeded' }, + { status: 503, body: 'Service unavailable' }, + ])( + 'retains reconciliation when result retrieval fails with HTTP $status', + async ({ status, body }) => { + let submissions = 0 + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.includes('/requests/')) return new Response(body, { status }) + if (path.endsWith('/billing-events')) return jsonResponse({ billing_events: [] }) + if (path.endsWith('/estimate')) return jsonResponse({ total_cost: 0.25 }) + submissions++ + return jsonResponse({ request_id: `unavailable-${status}` }) + }) + const receipts: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: `delivery-${status}`, + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + const rejection = expect(result).rejects.toMatchObject({ status: 502 }) + await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)]) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain(`unavailable-${status}`) + expect(receipts[0]).toContain('0.25 (historical_estimate)') + expect(submissions).toBe(1) + } + ) + + it.each(['headers', 'body'])( + 'captures the exact receipt before a stalled result %s settles', + async (stall) => { + let resultSignal: AbortSignal | null | undefined + const costs: number[] = [] + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + const path = new URL(url).pathname + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.endsWith('/billing-events')) + return jsonResponse({ + billing_events: [ + { + request_id: 'stalled-result', + endpoint_id: 'fal-ai/veo3.1', + cost_estimate_nano_usd: 400000000, + }, + ], + }) + if (path.includes('/requests/')) { + resultSignal = init?.signal + return stall === 'headers' + ? new Promise(() => {}) + : new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')) + }, + }) + ) + } + return jsonResponse({ request_id: 'stalled-result' }) + }) + const result = generateVideo( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'stall-key', + useHostedCostTracking: true, + }, + { + requestId: 'trace', + onFalCost: (cost) => { + costs.push(cost.costDollars) + }, + } + ) + let outcome: unknown = 'pending' + void result.then( + (value) => { + outcome = value + }, + (error) => { + outcome = error + } + ) + await vi.advanceTimersByTimeAsync(5000) + const earlyCosts = [...costs] + await vi.advanceTimersByTimeAsync(30001) + expect(earlyCosts).toEqual([0.4]) + expect(outcome).toMatchObject({ name: 'DeadlineExceededError' }) + expect(resultSignal?.aborted).toBe(true) + expect(costs).toEqual([0.4]) + } + ) + + it('reconciles an exact receipt for a terminal queue error without fetching or estimating a result', async () => { + const paths: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + paths.push(path) + if (path.endsWith('/status')) + return jsonResponse({ status: 'COMPLETED', error: 'sensitive provider error' }) + if (path.endsWith('/billing-events')) + return jsonResponse({ + billing_events: [ + { + request_id: 'queue-error', + endpoint_id: 'fal-ai/veo3.1', + cost_estimate_nano_usd: 400000000, + }, + ], + }) + return jsonResponse({ request_id: 'queue-error' }) + }) + const receipts: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'queue-key', + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + const rejected = expect(result).rejects.toThrow() + await Promise.all([rejected, vi.advanceTimersByTimeAsync(5000)]) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain('0.4 (billing_events)') + expect( + paths.some((path) => path.endsWith('/estimate') || path.endsWith('/requests/queue-error')) + ).toBe(false) + }) + + it('preserves an exact charge even when the provider returns a failed result', async () => { + vi.stubGlobal('fetch', async (url: string | URL) => { + const path = new URL(url).pathname + if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' }) + if (path.includes('/requests/')) + return jsonResponse({ detail: [{ type: 'no_media_generated' }] }, 422) + if (path.endsWith('/billing-events')) + return jsonResponse({ + billing_events: [ + { + request_id: 'charged-failure', + endpoint_id: 'fal-ai/veo3.1', + cost_estimate_nano_usd: 400000000, + }, + ], + }) + return jsonResponse({ request_id: 'charged-failure' }) + }) + const receipts: string[] = [] + const result = observeServiceCosts( + async () => {}, + () => + executeVideoOperation( + { + provider: 'falai', + model: 'veo-3.1', + prompt: 'A sunrise', + apiKey: 'key', + useHostedCostTracking: true, + }, + { requestId: 'trace', userId: 'user', headers: new Headers() } + ), + async (receipt) => { + receipts.push(receipt) + } + ) + const rejection = expect(result).rejects.toMatchObject({ status: 422 }) + await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)]) + expect(receipts).toHaveLength(1) + expect(receipts[0]).toContain('0.4 (billing_events)') + }) + it('preserves the completed hosted receipt for reconciliation when Stop interrupts delivery', async () => { const controller = new AbortController() const requestId = 'completed-video' diff --git a/apps/sim/lib/internal/video/client.ts b/apps/sim/lib/internal/video/client.ts index 7ce53e77161..6ef27ce4565 100644 --- a/apps/sim/lib/internal/video/client.ts +++ b/apps/sim/lib/internal/video/client.ts @@ -18,7 +18,8 @@ import { buildDirectVideoRequest } from '@/lib/internal/video/direct-request' import { buildFalVideoRequest, getFalAIValidationError } from '@/lib/internal/video/fal-request' import { getFalVideoModel } from '@/lib/media/fal-video-catalog' import { downloadFalMedia } from '@/lib/media/falai' -import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' +import { fetchFalAIResult } from '@/lib/media/falai-result' +import type { FalAICostMetadata } from '@/lib/tools/falai-pricing' import { downloadFileFromStorage } from '@/lib/uploads/utils/file.server' import type { UserFile } from '@/executor/types' @@ -102,7 +103,8 @@ export async function generateVideo( context: VideoGenerationContext ): Promise { context.signal?.throwIfAborted() - logger.info(`[${context.requestId}] Generating video with ${input.provider}`, { + logger.info(`Generating video with ${input.provider}`, { + requestId: context.requestId, model: input.model || 'default', }) return input.provider === 'falai' @@ -361,7 +363,7 @@ async function generateWithFalAI( const { apiKey, model, duration, aspectRatio, resolution, useHostedCostTracking } = input const { requestId, signal } = context signal?.throwIfAborted() - logger.info(`[${requestId}] Starting Fal.ai generation with model: ${model}`) + logger.info(`Starting Fal.ai generation with model: ${model}`, { requestId }) const { endpoint, body } = buildFalVideoRequest(input) const createResponse = await fetch(`https://queue.fal.run/${endpoint}`, { method: 'POST', @@ -396,7 +398,7 @@ async function generateWithFalAI( ) const responseUrl = falQueueUrl(getStringProperty(createData, 'response_url') || requestUrl) - logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`) + logger.info(`Fal.ai request created: ${requestIdFal}`, { requestId }) const maxAttempts = Math.ceil(getMaxExecutionTimeout() / POLL_INTERVAL_MS) let attempts = 0 @@ -423,36 +425,23 @@ async function generateWithFalAI( } if (getStringProperty(statusData, 'status') === 'COMPLETED') { - const statusError = statusData.error - if (statusError) { - throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`) - } - - logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`) - - const falaiCost = useHostedCostTracking - ? await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId: requestIdFal }) - : undefined - if (falaiCost) context.onFalCost?.(falaiCost) - signal?.throwIfAborted() - - const resultResponse = await fetch( - falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl), - { - redirect: 'error', - headers: { - Authorization: `Key ${apiKey}`, - }, - signal, - } - ) - - if (!resultResponse.ok) { - await readVideoErrorText(resultResponse, 'Fal.ai result error response') - throw new Error(`Failed to fetch result: ${resultResponse.status}`) - } + const { data: resultData, cost: falaiCost } = await fetchFalAIResult({ + apiKey, + endpointId: endpoint, + requestId: requestIdFal, + queueError: statusData.error, + maxResponseBytes: MAX_VIDEO_JSON_BYTES, + trackCost: useHostedCostTracking, + onCost: context.onFalCost, + signal, + fetchResponse: (resultSignal) => + fetch(falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl), { + redirect: 'error', + headers: { Authorization: `Key ${apiKey}` }, + signal: resultSignal, + }), + }) - const resultData = await readVideoJson(resultResponse, 'Fal.ai result response') if (!isRecordLike(resultData)) { throw new Error('Invalid Fal.ai result response') } diff --git a/apps/sim/lib/internal/video/execute-tool.ts b/apps/sim/lib/internal/video/execute-tool.ts index e55522e0349..0d35019d3e0 100644 --- a/apps/sim/lib/internal/video/execute-tool.ts +++ b/apps/sim/lib/internal/video/execute-tool.ts @@ -7,6 +7,7 @@ import type { InternalToolOperationHandler } from '@/lib/internal/tool-operation import type { VideoProvider } from '@/lib/internal/video/client' import { VideoOperationError } from '@/lib/internal/video/errors' import { executeVideoOperation } from '@/lib/internal/video/operations' +import { FalAIResultError } from '@/lib/media/falai-result' const VIDEO_TOOL_IDS = new Set([ 'video_falai', @@ -79,7 +80,14 @@ export const executeVideoTool: InternalToolOperationHandler = async (request) => } return Response.json( { error: getErrorMessage(error, 'Video generation failed') }, - { status: isPayloadSizeLimitError(error) ? 413 : 500 } + { + status: + error instanceof FalAIResultError + ? error.status + : isPayloadSizeLimitError(error) + ? 413 + : 500, + } ) } } diff --git a/apps/sim/lib/internal/video/operations.ts b/apps/sim/lib/internal/video/operations.ts index 3598310cdcd..a18a4858aa1 100644 --- a/apps/sim/lib/internal/video/operations.ts +++ b/apps/sim/lib/internal/video/operations.ts @@ -145,13 +145,13 @@ export async function executeVideoOperation( if (validationError) throw new VideoOperationError(validationError, 400) await authorizeVisualReference(input, context) - let completedFalCost: FalAICostMetadata | undefined + let undeliveredFalCost: FalAICostMetadata | undefined try { const generated = await generateVideo(input, { requestId: context.requestId, signal: context.signal, onFalCost: (cost) => { - completedFalCost = cost + undeliveredFalCost = cost }, }) context.signal?.throwIfAborted() @@ -169,16 +169,16 @@ export async function executeVideoOperation( __falaiBilling: generated.falaiCost, } } catch (error) { - if (completedFalCost) { - logger.warn('Hosted fal video completed but delivery failed', { + if (undeliveredFalCost) { + logger.warn('Hosted fal video result delivery interrupted', { requestId: context.requestId, - falaiBilling: completedFalCost, + falaiBilling: undeliveredFalCost, }) - const { requestId, endpointId, costDollars } = completedFalCost + const { requestId, endpointId, costDollars } = undeliveredFalCost await recordServiceMeteringFailure( - `Completed fal video ${requestId} (${endpointId}): USD ${costDollars}; delivery failed` + `Undelivered fal video ${requestId} (${endpointId}): USD ${costDollars} (${undeliveredFalCost.source}); reconciliation required` ).catch((meteringError) => { - logger.error('Failed to record completed fal video for reconciliation', { + logger.error('Failed to record undelivered fal video for reconciliation', { requestId: context.requestId, error: getErrorMessage(meteringError), }) diff --git a/apps/sim/lib/internal/windchill/execute-tool.ts b/apps/sim/lib/internal/windchill/execute-tool.ts index b49cf727672..a669f7c692b 100644 --- a/apps/sim/lib/internal/windchill/execute-tool.ts +++ b/apps/sim/lib/internal/windchill/execute-tool.ts @@ -105,7 +105,8 @@ export const executeWindchillTool: InternalToolOperationHandler = async (request internalToolIdentityFaultStatus(identityFault) ) } - logger.error(`[${requestId}] Windchill operation failed`, { + logger.error('Windchill operation failed', { + requestId, operation: toolId, error: sanitizeWindchillError(getErrorMessage(error, 'Windchill operation failed')), }) diff --git a/apps/sim/lib/internal/zoom/operations.ts b/apps/sim/lib/internal/zoom/operations.ts index 1c93c350421..98ef6429161 100644 --- a/apps/sim/lib/internal/zoom/operations.ts +++ b/apps/sim/lib/internal/zoom/operations.ts @@ -143,7 +143,8 @@ export async function getZoomMeetingRecordings( 413 ) } - logger.warn(`[${context.requestId}] Failed to download Zoom recording file`, { + logger.warn('Failed to download Zoom recording file', { + requestId: context.requestId, fileId: file.id, }) } diff --git a/apps/sim/lib/invitations/error-messages.ts b/apps/sim/lib/invitations/error-messages.ts index 565b3e56990..b16c41591bc 100644 --- a/apps/sim/lib/invitations/error-messages.ts +++ b/apps/sim/lib/invitations/error-messages.ts @@ -1,3 +1,5 @@ +import type { BatchInvitationResult } from '@/lib/api/contracts/invitations' + /** * Human-readable copy for invitation accept/decline failures. The accept and * reject routes return `{ error: }` where `` is a machine code @@ -35,3 +37,37 @@ const INVITATION_ERROR_MESSAGES: Record = { export function getInvitationErrorMessage(code: string, fallback: string): string { return INVITATION_ERROR_MESSAGES[code] ?? fallback } + +/** Distinct failure reasons listed in full before the remainder is counted instead. */ +const MAX_LISTED_FAILURE_REASONS = 3 + +/** + * Builds the submit error for a batch where some or all invitations failed. + * + * The server rejects per email and every reason names its own address, so the + * reasons are listed rather than collapsed to the first one: inviting several + * people who are all ineligible for the chosen membership is the common + * multi-failure case, and reporting one of them hides who else needs attention. + * Identical reasons are deduplicated, and beyond + * {@link MAX_LISTED_FAILURE_REASONS} the tail is counted rather than listed so a + * large batch cannot bury the modal in near-identical sentences. + */ +export function buildInviteFailureMessage( + failures: BatchInvitationResult['failed'], + attemptedCount: number +): string { + if (failures.length === 1) return failures[0].error + + const headline = + failures.length >= attemptedCount + ? `None of the ${failures.length} invitations could be sent.` + : `${failures.length} of ${attemptedCount} invitations could not be sent.` + + const reasons = [...new Set(failures.map((failure) => failure.error))] + const listed = reasons.slice(0, MAX_LISTED_FAILURE_REASONS) + const remaining = reasons.length - listed.length + + return [headline, ...listed, remaining > 0 ? `And ${remaining} more.` : null] + .filter(Boolean) + .join(' ') +} diff --git a/apps/sim/lib/jama/client.test.ts b/apps/sim/lib/jama/client.test.ts new file mode 100644 index 00000000000..12d17d52e43 --- /dev/null +++ b/apps/sim/lib/jama/client.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { normalizeJamaInstanceUrl } from '@/lib/jama/client' + +describe('Jama instance URLs', () => { + it.each([ + '', + 'example.jamacloud.com', + 'https://', + 'http://example.jamacloud.com', + 'https://example.jamacloud.com/rest/v1', + ])('explains how to fix an invalid instance URL %j', (url) => { + expect(() => normalizeJamaInstanceUrl(url)).toThrow( + 'Enter your Jama HTTPS instance URL, without an API path' + ) + }) + + it('preserves the deployment context path while normalizing the origin and trailing slash', () => { + expect(normalizeJamaInstanceUrl(' https://Jama.example.com/context/ ')).toBe( + 'https://jama.example.com/context' + ) + }) +}) diff --git a/apps/sim/lib/jama/client.ts b/apps/sim/lib/jama/client.ts new file mode 100644 index 00000000000..73550088604 --- /dev/null +++ b/apps/sim/lib/jama/client.ts @@ -0,0 +1,17 @@ +/** Keeps the credential's HTTPS instance and optional deployment context path together. */ +export function normalizeJamaInstanceUrl(value: string): string { + const url = URL.parse(value.trim()) + if ( + !url || + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + /%(?:2f|5c|2e)/i.test(url.pathname) || + /\/rest(?:\/|$)/i.test(url.pathname) + ) { + throw new Error('Enter your Jama HTTPS instance URL, without an API path') + } + return `${url.origin}${url.pathname.replace(/\/+$/, '')}` +} diff --git a/apps/sim/lib/knowledge/application/connect-personal-search-integration.ts b/apps/sim/lib/knowledge/application/connect-personal-search-integration.ts index bf24747149d..09b363dda24 100644 --- a/apps/sim/lib/knowledge/application/connect-personal-search-integration.ts +++ b/apps/sim/lib/knowledge/application/connect-personal-search-integration.ts @@ -1,3 +1,4 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' import { startOrganizationAccountConnection } from '@/lib/credential-groups/application/organization-accounts' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' @@ -18,6 +19,11 @@ export const connectPersonalSearchIntegration = defineAuthorizedKnowledgeUseCase resolveKnowledgeOrganizationContext(input), async execute({ principal, input, request }) { const { target } = await resolvePersonalSearchConnection.execute({ principal, input }) + if (!target.optionId) + throw new OrchestrationError( + 'validation', + 'Connect this account using the personal credentials form.' + ) const result = await startOrganizationAccountConnection.execute({ principal, request, diff --git a/apps/sim/lib/knowledge/application/github-installations.integration.ts b/apps/sim/lib/knowledge/application/github-installations.integration.ts index 459aca2b373..bd76eb9cca6 100644 --- a/apps/sim/lib/knowledge/application/github-installations.integration.ts +++ b/apps/sim/lib/knowledge/application/github-installations.integration.ts @@ -34,7 +34,10 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ getPolicy: async () => ({ authorizationAppId: 'current-app', scopeVersion: 1 }), }), })) -vi.mock('@/lib/oauth/github-installation', () => ({ +vi.mock('@/lib/oauth/github-installation', async (importOriginal) => ({ + GitHubInstallationError: ( + await importOriginal() + ).GitHubInstallationError, getGitHubInstallationConfiguration: () => ({ configured: true, installUrl: 'https://github.com/apps/example/installations/new', @@ -45,6 +48,7 @@ vi.mock('@/lib/oauth/github-installation', () => ({ vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: vi.fn() })) import { listGitHubSearchInstallations } from '@/lib/knowledge/application/github-installations' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' const { drizzle } = await import('drizzle-orm/postgres-js') const schema = await import('@sim/db/schema') @@ -111,6 +115,85 @@ describe('GitHub installation setup reader ownership in PostgreSQL', () => { expect(mocks.token).toHaveBeenCalledTimes(1) }) + describe('with several GitHub accounts connected', () => { + const installation = (installationId: string) => ({ + installationId, + accountLogin: installationId, + }) + + beforeEach(async () => { + await fixture.client.unsafe(` + INSERT INTO credential(id,organization_id,type,provider_id,provider_subject_id,authorization_app_id, + managed_oauth_status,managed_oauth_scope_version,granted_scopes,encrypted_oauth_token_set,granted_at, + credential_group_enrollment_id,credential_group_option_id) + VALUES ('alice-github-2','setup-org','managed_oauth','github-repositories','456','current-app','active',1, + ARRAY[]::text[],'encrypted',now(),'setup-enrollment','github-option'); + `) + mocks.token.mockImplementation(async ({ credentialId }: { credentialId: string }) => ({ + accessToken: `token-${credentialId}`, + })) + }) + + it('lists installations from each account once', async () => { + mocks.list.mockImplementation(async (token: string) => + token === 'token-alice-github' + ? [installation('shared'), installation('work')] + : [installation('shared'), installation('personal')] + ) + const result = await list() + expect(result.needsUserConnection).toBe(false) + expect(result.installations.map(({ installationId }) => installationId).sort()).toEqual([ + 'personal', + 'shared', + 'work', + ]) + }) + + it('does not ask to reconnect while another account still answers', async () => { + mocks.list.mockImplementation(async (token: string) => { + if (token === 'token-alice-github') throw new GitHubInstallationError('expired', 401) + return [installation('personal')] + }) + expect(await list()).toMatchObject({ + needsUserConnection: false, + installations: [installation('personal')], + }) + }) + + it('still lists through one account when another lacks organization access', async () => { + mocks.list.mockImplementation(async (token: string) => { + if (token === 'token-alice-github') + throw new GitHubInstallationError('Approve access', 403, 'membership-permissions') + return [installation('personal')] + }) + expect(await list()).toMatchObject({ + needsUserConnection: false, + installations: [installation('personal')], + }) + }) + + it('reports the failure when no account answers', async () => { + mocks.list.mockImplementation(async (token: string) => { + if (token === 'token-alice-github') + throw new GitHubInstallationError('Approve access', 403, 'membership-permissions') + throw new GitHubInstallationError('expired', 401) + }) + await expect(list()).rejects.toThrow('Approve access') + }) + + it('stays within the setup limit when accounts together see too many', async () => { + mocks.list.mockImplementation(async (token: string) => + Array.from({ length: 600 }, (_, index) => installation(`${token}-${index}`)) + ) + expect((await list()).installations).toHaveLength(1000) + }) + + it('asks to reconnect once no account answers', async () => { + mocks.list.mockRejectedValue(new GitHubInstallationError('expired', 401)) + expect(await list()).toMatchObject({ needsUserConnection: true, installations: [] }) + }) + }) + it.each([ ['credential owner', "UPDATE credential SET organization_id='other-org'"], ['group owner', "UPDATE credential_group SET organization_id='other-org'"], diff --git a/apps/sim/lib/knowledge/application/github-installations.test.ts b/apps/sim/lib/knowledge/application/github-installations.test.ts index 2e5afd9199b..b37ebc9c8dd 100644 --- a/apps/sim/lib/knowledge/application/github-installations.test.ts +++ b/apps/sim/lib/knowledge/application/github-installations.test.ts @@ -185,6 +185,29 @@ describe('GitHub Search installation application operations', () => { await expect(connect()).rejects.toMatchObject({ code: 'forbidden' }) expect(dbChainMockFns.values).not.toHaveBeenCalled() }) + it('verifies through whichever connected account administers the installation', async () => { + const other = { ...reader, id: 'other-reader', subjectId: '10' } + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [other, reader]) + m.verify.mockRejectedValueOnce(new GitHubInstallationError('Not an admin', 403)) + queueTableRows(member, [{ id: 'admin-membership' }]) + queueTableRows(credentialGroup, [{ id: 'group' }]) + queueTableRows(credential, [other, reader]) + queueTableRows(credential, []) + + await expect(connect()).resolves.toMatchObject({ created: true }) + expect(m.verify).toHaveBeenCalledTimes(2) + }) + it('reports why no connected account could verify the installation', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [reader, { ...reader, id: 'other-reader' }]) + m.verify + .mockRejectedValueOnce(new GitHubInstallationError('Not an admin', 403)) + .mockRejectedValueOnce(new GitHubInstallationError('Not an admin', 403)) + + await expect(connect()).rejects.toThrow('Not an admin') + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) it('refuses if the reader identity changed during GitHub verification', async () => { setupReader() setupTransaction({ ...reader, subjectId: 'different-person' }) diff --git a/apps/sim/lib/knowledge/application/github-installations.ts b/apps/sim/lib/knowledge/application/github-installations.ts index 3766cd602c9..13e77c87567 100644 --- a/apps/sim/lib/knowledge/application/github-installations.ts +++ b/apps/sim/lib/knowledge/application/github-installations.ts @@ -8,10 +8,11 @@ import { member, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { LIVE_ENROLLMENT_STATUSES } from '@/lib/credential-groups/credentials' +import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import { ManagedOAuthCredentialError, @@ -43,8 +44,8 @@ interface ConnectInstallationInput extends InstallationInput { installationId: string } -/** Selects only the acting person's live, organization-bound GitHub connection. */ -export async function findGitHubSearchReaderCredential( +/** Selects only the acting person's live, organization-bound GitHub connections. */ +export async function listGitHubSearchReaderCredentials( executor: DbOrTx, organizationId: string, userId: string @@ -53,7 +54,7 @@ export async function findGitHubSearchReaderCredential( undefined, { organizationId } ) - const rows = await executor + return executor .select({ id: credential.id, authorizationAppId: credential.authorizationAppId, @@ -85,13 +86,8 @@ export async function findGitHubSearchReaderCredential( AND option->>'provider' = 'github-repositories' AND option->>'status' = 'active')` ) ) - .limit(2) - if (rows.length > 1) - throw new OrchestrationError( - 'conflict', - 'Connect one GitHub account for this organization before choosing an installation' - ) - return rows[0] ?? null + .orderBy(asc(credential.id)) + .limit(CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT) } async function readerToken(organizationId: string, credentialId: string) { @@ -103,6 +99,54 @@ async function readerToken(organizationId: string, credentialId: string) { }) } +const MAX_LISTED_INSTALLATIONS = 1000 + +/** A null answer means only this account needs reconnecting; App JWT failures stay errors. */ +async function listInstallationsThroughReader( + organizationId: string, + credentialId: string, + signal?: AbortSignal +): Promise { + try { + return await listUserAdminGitHubInstallations( + (await readerToken(organizationId, credentialId)).accessToken, + { signal } + ) + } catch (error) { + if (error instanceof GitHubInstallationError && error.operation === 'membership-permissions') + throw new OrchestrationError('validation', error.message) + if ( + (error instanceof GitHubInstallationError && error.status === 401) || + (error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH') + ) + return null + throw error + } +} + +/** The person may administer the installation through only one of their GitHub accounts. */ +async function verifyInstallationThroughReaders( + organizationId: string, + readers: Awaited>, + installationId: string, + signal?: AbortSignal +) { + let failure: unknown + for (const reader of readers) { + try { + const { accessToken } = await readerToken(organizationId, reader.id) + return { + reader, + binding: await verifyGitHubInstallationBinding(accessToken, installationId, { signal }), + } + } catch (error) { + failure = error + } + } + throw failure +} + export const listGitHubSearchInstallations = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.listGitHubInstallations, resolveContext: ({ input }: { input: InstallationInput }) => @@ -110,33 +154,28 @@ export const listGitHubSearchInstallations = defineAuthorizedKnowledgeUseCase({ async execute({ principal, input, context }) { await requireOrganizationSearchAvailable(context.organizationId) const configuration = getGitHubInstallationConfiguration() - const reader = configuration.configured - ? await findGitHubSearchReaderCredential(db, context.organizationId, principal.userId) - : null - 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) { - if ( - error instanceof GitHubInstallationError && - error.operation === 'membership-permissions' - ) - throw new OrchestrationError('validation', error.message) - /** 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 - } + const readers = configuration.configured + ? await listGitHubSearchReaderCredentials(db, context.organizationId, principal.userId) + : [] + const answers = await Promise.allSettled( + readers.map((reader) => + listInstallationsThroughReader(context.organizationId, reader.id, input.signal) + ) + ) + const answered = answers.flatMap((answer) => + answer.status === 'fulfilled' && answer.value ? [answer.value] : [] + ) + // One account's failure stays hidden while another account still answers. + if (answered.length === 0) { + const failed = answers.find((answer) => answer.status === 'rejected') + if (failed) throw failed.reason } + const needsUserConnection = configuration.configured && answered.length === 0 + const installations = [ + ...new Map( + answered.flat().map((installation) => [installation.installationId, installation]) + ).values(), + ].slice(0, MAX_LISTED_INSTALLATIONS) return { available: configuration.configured, installUrl: configuration.installUrl, @@ -157,20 +196,22 @@ export const connectGitHubSearchInstallation = defineAuthorizedKnowledgeUseCase( 'validation', 'GitHub App installation indexing is not configured for this environment' ) - const reader = await findGitHubSearchReaderCredential( + const readers = await listGitHubSearchReaderCredentials( db, context.organizationId, principal.userId ) - if (!reader) + if (readers.length === 0) throw new OrchestrationError( 'validation', 'Connect your GitHub account before choosing an installation' ) - const { accessToken } = await readerToken(context.organizationId, reader.id) - const binding = await verifyGitHubInstallationBinding(accessToken, input.installationId, { - signal: input.signal, - }) + const { reader, binding } = await verifyInstallationThroughReaders( + context.organizationId, + readers, + input.installationId, + input.signal + ) const { encrypted } = await encryptSecret(JSON.stringify(binding)) return db.transaction(async (tx) => { await acquireAdvisoryXactLock( @@ -204,13 +245,11 @@ export const connectGitHubSearchInstallation = defineAuthorizedKnowledgeUseCase( ) .for('update') .limit(1) - const current = await findGitHubSearchReaderCredential( - tx, - context.organizationId, - principal.userId - ) + const current = ( + await listGitHubSearchReaderCredentials(tx, context.organizationId, principal.userId) + ).find((candidate) => candidate.id === reader.id) if ( - current?.id !== reader.id || + !current || current.authorizationAppId !== reader.authorizationAppId || current.subjectId !== reader.subjectId ) diff --git a/apps/sim/lib/knowledge/application/github-setup.test.ts b/apps/sim/lib/knowledge/application/github-setup.test.ts index 1edcdc4982a..dc55e9fb8fa 100644 --- a/apps/sim/lib/knowledge/application/github-setup.test.ts +++ b/apps/sim/lib/knowledge/application/github-setup.test.ts @@ -76,7 +76,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveM vi.mock('@/lib/knowledge/application/github-installations', () => ({ listGitHubSearchInstallations: { execute: hoisted.list }, connectGitHubSearchInstallation: { execute: hoisted.connect }, - findGitHubSearchReaderCredential: hoisted.reader, + listGitHubSearchReaderCredentials: hoisted.reader, })) vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ provisionKnowledgeConnectorMembersBinding: hoisted.provision, @@ -204,7 +204,7 @@ beforeEach(() => { m.values.clear() m.list.mockResolvedValue({ available: true, needsUserConnection: false, installations: [] }) m.connect.mockResolvedValue({ credential: resultCredential, created: true }) - m.reader.mockResolvedValue(reader) + m.reader.mockResolvedValue([reader]) m.receipt.mockResolvedValue(null) m.provision.mockResolvedValue({ credentialGroupId: 'group', credentialGroupOptionId: 'option' }) m.enrollment.mockResolvedValue({ @@ -329,6 +329,16 @@ describe('GitHub setup reader OAuth continuation', () => { queueTableRows(credential, []) return start(intent) } + it('sends a person with several expired accounts to reconnect the right one', async () => { + m.list.mockResolvedValueOnce({ available: true, needsUserConnection: true, installations: [] }) + queueTableRows(credential, [{ id: 'first' }, { id: 'second' }]) + await start() + expect(m.oauthStart).not.toHaveBeenCalled() + await expect(status()).resolves.toEqual({ + status: 'failed', + error: 'Reconnect one of your GitHub accounts in Integrations, then try setup again.', + }) + }) it('a revoked enrollment stays denied before provider authorization', async () => { m.enrollment.mockRejectedValueOnce(new Error('revoked')) await startOAuth() diff --git a/apps/sim/lib/knowledge/application/github-setup.ts b/apps/sim/lib/knowledge/application/github-setup.ts index cbe05dd973b..9b2c39e3f9e 100644 --- a/apps/sim/lib/knowledge/application/github-setup.ts +++ b/apps/sim/lib/knowledge/application/github-setup.ts @@ -3,7 +3,7 @@ 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 { and, eq, isNull } 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' @@ -20,8 +20,8 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' import { connectGitHubSearchInstallation, - findGitHubSearchReaderCredential, listGitHubSearchInstallations, + listGitHubSearchReaderCredentials, } from '@/lib/knowledge/application/github-installations' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { provisionKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-provisioning' @@ -244,7 +244,7 @@ async function authorizeReader(attempt: GitHubSetupAttempt) { ) if (!context) throw new OrchestrationError('forbidden', 'This account connection is no longer available.') - const [existing] = await db + const connected = await db .select({ id: credential.id }) .from(credential) .where( @@ -252,10 +252,18 @@ async function authorizeReader(attempt: GitHubSetupAttempt) { eq(credential.organizationId, attempt.organizationId), eq(credential.type, 'managed_oauth'), eq(credential.credentialGroupEnrollmentId, enrollment.id), - eq(credential.credentialGroupOptionId, binding.credentialGroupOptionId) + eq(credential.credentialGroupOptionId, binding.credentialGroupOptionId), + isNull(credential.revokedAt) ) ) - .limit(1) + .limit(2) + // GitHub signs in whoever its session holds, so setup cannot aim at one of several accounts. + if (connected.length > 1) + throw new OrchestrationError( + 'validation', + 'Reconnect one of your GitHub accounts in Integrations, then try setup again.' + ) + const [existing] = connected const url = await startCredentialGroupOAuth(context, token, { completionRedirect: true, completionId: attempt.setupId, @@ -383,12 +391,12 @@ export const continueGitHubSearchSetup = defineAuthorizedKnowledgeUseCase({ ...scope, completionId: input.setupId, }) - const reader = await findGitHubSearchReaderCredential( + const readers = await listGitHubSearchReaderCredentials( db, input.organizationId, principal.userId ) - if (!receipt || reader?.id !== receipt) + if (!receipt || !readers.some((reader) => reader.id === receipt)) throw new OrchestrationError( 'forbidden', 'Complete GitHub authorization before continuing setup.' diff --git a/apps/sim/lib/knowledge/application/personal-search-integrations.test.ts b/apps/sim/lib/knowledge/application/personal-search-integrations.test.ts index ea8ceff3597..e71f255cd65 100644 --- a/apps/sim/lib/knowledge/application/personal-search-integrations.test.ts +++ b/apps/sim/lib/knowledge/application/personal-search-integrations.test.ts @@ -1,4 +1,4 @@ -import { user } from '@sim/db/schema' +import { credential, user } from '@sim/db/schema' import { queueTableRows, resetDbChainMock, resetEnvFlagsMock } from '@sim/testing' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { @@ -31,6 +31,10 @@ const hoisted = vi.hoisted(() => ({ configuredTypes: vi.fn(), accounts: vi.fn(), completion: vi.fn(), + providerCatalog: vi.fn(), +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: hoisted.providerCatalog, })) vi.mock('@/lib/credential-groups/service', () => credentialGroupsServiceMock) vi.mock('@/lib/credential-groups/viewer-accounts', () => ({ @@ -109,6 +113,7 @@ beforeEach(() => { }) m.accounts.mockResolvedValue([]) m.completion.mockResolvedValue(null) + m.providerCatalog.mockResolvedValue([]) queueTableRows(user, [{ emailVerified: true }]) organizationAuthorizationMockFns.mockAuthorizeOrganizationOperation.mockResolvedValue(undefined) m.sources.mockResolvedValue({ sources: [source], nextCursor: null }) @@ -123,6 +128,61 @@ beforeEach(() => { }) describe('live Search connection controls', () => { + it.each(['jama', 'flow'] as const)( + 'keeps %s reconnect controls but withholds another connection at the limit', + async (connectorType) => { + const providerId = `${connectorType}-service-account` + const rows = Array.from({ length: 5 }, (_, index) => ({ + id: `${connectorType}-${index}`, + providerId, + displayName: `account-${index}@example.test`, + })) + mockListOrganizationSearchApprovals.mockResolvedValue(new Map([[connectorType, true]])) + m.providerCatalog.mockResolvedValue([ + { type: 'service_account', providerId, available: true }, + ]) + const request = { principal, input: { ...input, connectorType } } + queueTableRows(credential, rows.slice(0, 4)) + expect((await listPersonalSearchIntegrations.execute(request)).available).toHaveLength(1) + + queueTableRows(user, [{ emailVerified: true }]) + queueTableRows(credential, rows) + const full = await listPersonalSearchIntegrations.execute(request) + expect(full.available).toEqual([]) + expect( + full.connections.flatMap((connection) => + connection.accounts.map((account) => account.action?.credentialId) + ) + ).toEqual(rows.map((row) => row.id)) + } + ) + + it.each(['jama', 'flow'])( + 'withholds %s setup when the credential provider is unavailable', + async (connectorType) => { + mockListOrganizationSearchApprovals.mockResolvedValue(new Map([[connectorType, true]])) + m.providerCatalog.mockResolvedValue([ + { + type: 'service_account', + providerId: `${connectorType}-service-account`, + available: false, + }, + ]) + const request = { principal, input: { ...input, connectorType } } + expect((await listPersonalSearchIntegrations.execute(request)).available).toEqual([]) + + m.providerCatalog.mockResolvedValue([ + { + type: 'service_account', + providerId: `${connectorType}-service-account`, + available: true, + }, + ]) + queueTableRows(user, [{ emailVerified: true }]) + expect((await listPersonalSearchIntegrations.execute(request)).available).toHaveLength(1) + } + ) + const liveTarget = { type: 'link', provider: 'slack', diff --git a/apps/sim/lib/knowledge/application/personal-search-integrations.ts b/apps/sim/lib/knowledge/application/personal-search-integrations.ts index 95f444d8f7d..93af073aeb0 100644 --- a/apps/sim/lib/knowledge/application/personal-search-integrations.ts +++ b/apps/sim/lib/knowledge/application/personal-search-integrations.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import { loadAssistantIntegrationTools } from '@/lib/assistant-actions/catalog' import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT } from '@/lib/credential-groups/limits' import { findCredentialGroupProviderFromProviderId, getCredentialGroupProviderId, @@ -14,13 +15,19 @@ import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scope import { readSearchConnectionCompletion } from '@/lib/credential-groups/search-connection-completion' import { getOrganizationAccountsGroup } from '@/lib/credential-groups/service' import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts' +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' 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 type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' import { listOrganizationSearchApprovals } from '@/lib/knowledge/search/integration-policy' import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { listSavedSearchCredentials } from '@/lib/sim-search/live/accounts' import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' +import { + LIVE_SEARCH_SOURCE_TYPES, + liveSearchSavedMemberProvider, +} from '@/lib/sim-search/live/source-catalog' export interface ListPersonalSearchIntegrationsInput { organizationId: string @@ -44,10 +51,11 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ const scope = { kind: 'organization', organizationId: context.organizationId } as const if (!(await isScopedCredentialGroupsAvailable(scope))) return { completedCredentialId: null, connections: [], available: [], nextCursor: null } - const [group, approvals, assistant] = await Promise.all([ + const [group, approvals, assistant, providerCatalog] = await Promise.all([ getOrganizationAccountsGroup(context.organizationId), listOrganizationSearchApprovals(context.organizationId), loadAssistantIntegrationTools({ organizationId: context.organizationId, userId }), + listCredentialProviderCatalog(principal, context), ]) const accounts = group ? await listViewerOrganizationAccounts({ @@ -106,10 +114,61 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ : own.length ? ('connected' as const) : ('not_connected' as const), - action: ready ? target : null, + action: + ready && own.length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT ? target : null, + }, + ] + }) + const saved = await listSavedSearchCredentials(scope, userId) + const availableSavedProviders = new Set( + providerCatalog.flatMap((entry) => + entry.type === 'service_account' && entry.available ? [entry.providerId] : [] + ) + ) + const savedConnections = LIVE_SEARCH_SOURCE_TYPES.flatMap(([connectorType, meta]) => { + const providerId = liveSearchSavedMemberProvider(connectorType) + if ( + (providerId !== 'jama-service-account' && providerId !== 'flow-service-account') || + (input.connectorType && input.connectorType !== connectorType) + ) + return [] + const ready = + viewer.emailVerified && + approvals.get(connectorType) === true && + availableSavedProviders.has(providerId) + const target: SearchConnectionTarget = { + type: 'link', + provider: providerId, + connectorType, + connectionMode: 'live', + savedProviderId: providerId, + } + const own = saved + .filter((row) => row.providerId === providerId) + .map((row) => ({ + credentialId: row.id, + displayName: row.displayName, + status: 'connected' as const, + action: ready ? { ...target, credentialId: row.id } : null, + })) + return [ + { + name: meta.name, + providerId, + connectorType, + description: 'Personal account for live Search', + accounts: own, + connectionStatus: ready + ? own.length + ? ('connected' as const) + : ('not_connected' as const) + : ('unavailable' as const), + action: + ready && own.length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT ? target : null, }, ] }) + const allConnections = [...connections, ...savedConnections] return { completedCredentialId: input.completionId ? await readSearchConnectionCompletion({ @@ -118,8 +177,8 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ completionId: input.completionId, }) : null, - connections: connections.filter((entry) => entry.accounts.length > 0), - available: connections.flatMap((entry) => + connections: allConnections.filter((entry) => entry.accounts.length > 0), + available: allConnections.flatMap((entry) => entry.action ? [{ name: entry.name, description: entry.description, target: entry.action }] : [] @@ -159,7 +218,8 @@ export const resolvePersonalSearchConnection = defineAuthorizedKnowledgeUseCase( target.connectorType === input.target.connectorType && target.credentialId === input.target.credentialId && target.connectionMode === input.target.connectionMode && - target.optionId === input.target.optionId + target.optionId === input.target.optionId && + target.savedProviderId === input.target.savedProviderId ) if (!selected) throw new OrchestrationError( diff --git a/apps/sim/lib/knowledge/application/search-integrations.test.ts b/apps/sim/lib/knowledge/application/search-integrations.test.ts index 770e513daa2..8c296f13e49 100644 --- a/apps/sim/lib/knowledge/application/search-integrations.test.ts +++ b/apps/sim/lib/knowledge/application/search-integrations.test.ts @@ -77,6 +77,38 @@ beforeEach(() => { }) describe('organization Search approval', () => { + it.each([ + { unavailable: 'jama', available: 'flow' }, + { unavailable: 'flow', available: 'jama' }, + ])( + 'does not offer approved $unavailable when its credential provider is restricted', + async ({ unavailable, available }) => { + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(organization, [{ metadata: {} }]) + queueTableRows(organizationSearchIntegration, [ + { connectorType: unavailable, approved: true }, + { connectorType: available, approved: true }, + ]) + permissionGroupsResolveMockFns.mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + allowedIntegrations: [available], + }) + + const integrations = await listSearchIntegrations.execute({ + principal, + input: { organizationId: 'organization' }, + }) + + expect(integrations.find((entry) => entry.connectorType === unavailable)).toMatchObject({ + approved: true, + available: false, + }) + expect(integrations.find((entry) => entry.connectorType === available)).toMatchObject({ + approved: true, + available: true, + }) + } + ) + it.each(['owner', 'admin'])( 'allows %s approval without creating sources, credentials or an index', async (role) => { diff --git a/apps/sim/lib/knowledge/application/search-integrations.ts b/apps/sim/lib/knowledge/application/search-integrations.ts index 6347cabd1fe..b82e54eeb0d 100644 --- a/apps/sim/lib/knowledge/application/search-integrations.ts +++ b/apps/sim/lib/knowledge/application/search-integrations.ts @@ -16,6 +16,7 @@ import { addOrganizationAccountProvider, ensureWorkspaceAccountsGroup, } from '@/lib/credential-groups/service' +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' @@ -46,6 +47,7 @@ import { LIVE_SEARCH_SOURCE_TYPES, liveSearchMcpConnector, liveSearchMemberAccountProvider, + liveSearchSavedMemberProvider, } from '@/lib/sim-search/live/source-catalog' import { getConnectorMeta } from '@/connectors/registry' @@ -62,23 +64,36 @@ export const listSearchIntegrations = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.listSearchIntegrations, resolveContext: ({ input }: { input: SearchIntegrationInput }) => resolveKnowledgeOwnerContext({ organizationId: input.organizationId }), - async execute({ context }) { + async execute({ context, principal }) { if (!context.organizationId) throw new OrchestrationError('validation', 'Organization is required') const approvals = await listOrganizationSearchApprovals(context.organizationId) const policies = await loadLiveSearchPolicies({ organizationId: context.organizationId }) const scope = { kind: 'organization', organizationId: context.organizationId } as const - const zoomEnabled = await isSearchProviderEnabled('zoom', scope) - const integrations = LIVE_SEARCH_SOURCE_TYPES.map(([connectorType]) => ({ - connectorType, - approved: approvals.get(connectorType) ?? false, - ...(policies - ? { - policy: livePolicyFor(policies, connectorType), - available: connectorType !== 'zoom' || zoomEnabled, - } - : {}), - })) + const [zoomEnabled, providerCatalog] = await Promise.all([ + isSearchProviderEnabled('zoom', scope), + listCredentialProviderCatalog(principal, { organizationId: context.organizationId }), + ]) + const availableSavedProviders = new Set( + providerCatalog.flatMap((entry) => + entry.type === 'service_account' && entry.available ? [entry.providerId] : [] + ) + ) + const integrations = LIVE_SEARCH_SOURCE_TYPES.map(([connectorType]) => { + const savedProviderId = liveSearchSavedMemberProvider(connectorType) + return { + connectorType, + approved: approvals.get(connectorType) ?? false, + ...(policies + ? { + policy: livePolicyFor(policies, connectorType), + available: + (connectorType !== 'zoom' || zoomEnabled) && + (!savedProviderId || availableSavedProviders.has(savedProviderId)), + } + : {}), + } + }) const servicePolicies = integrations.filter( (integration) => integration.approved && diff --git a/apps/sim/lib/knowledge/application/slack-search/identity.test.ts b/apps/sim/lib/knowledge/application/slack-search/identity.test.ts index 5443f286648..c31bcf5b896 100644 --- a/apps/sim/lib/knowledge/application/slack-search/identity.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/identity.test.ts @@ -47,11 +47,11 @@ describe('Slack email to Sim member resolution', () => { 'verified Sim account' ) }) - it('refuses a contradictory active Slack identity', async () => { + it('refuses a Slack identity already linked to another person', async () => { limit .mockResolvedValueOnce([{ id: 'u1', emailVerified: true }]) .mockResolvedValueOnce([{ id: 'm1' }]) - .mockResolvedValueOnce([{ id: 'conflict' }]) + .mockResolvedValueOnce([{ subjectId: 'U1', userId: 'u2' }]) await expect(resolveSlackSearchMember('org1', 'T1', 'U1', 'alice@example.com')).rejects.toThrow( 'verified Sim account' ) diff --git a/apps/sim/lib/knowledge/application/slack-search/identity.ts b/apps/sim/lib/knowledge/application/slack-search/identity.ts index a8e853e4cb5..1bf92e37766 100644 --- a/apps/sim/lib/knowledge/application/slack-search/identity.ts +++ b/apps/sim/lib/knowledge/application/slack-search/identity.ts @@ -6,7 +6,7 @@ import { member, user, } from '@sim/db/schema' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, or, sql } from 'drizzle-orm' import { LIVE_ENROLLMENT_STATUSES } from '@/lib/credential-groups/credentials' export class SlackSearchIdentityError extends Error { @@ -24,6 +24,9 @@ export class SlackSearchIdentityError extends Error { } } +/** Beyond one person's accounts, every further row is another person's claim: already a conflict. */ +const LINKED_IDENTITY_SCAN_LIMIT = 50 + /** Email matching never creates membership and refuses global email collisions or conflicting linked identities. */ export async function resolveSlackSearchMember( organizationId: string, @@ -46,8 +49,8 @@ export async function resolveSlackSearchMember( .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) .limit(1) if (!membership) throw new SlackSearchIdentityError('membership_required') - const conflicts = await db - .select({ id: credential.id }) + const linked = await db + .select({ subjectId: credential.providerSubjectId, userId: credentialGroupEnrollment.userId }) .from(credential) .innerJoin( credentialGroupEnrollment, @@ -69,10 +72,18 @@ export async function resolveSlackSearchMember( AND option->>'status' = 'active' )`, eq(credential.providerTenantId, teamId), - sql`((${credential.providerSubjectId} = ${slackUserId} AND ${credentialGroupEnrollment.userId} IS DISTINCT FROM ${userId}) OR (${credentialGroupEnrollment.userId} = ${userId} AND ${credential.providerSubjectId} IS DISTINCT FROM ${slackUserId}))` + or( + eq(credential.providerSubjectId, slackUserId), + eq(credentialGroupEnrollment.userId, userId) + ) ) ) - .limit(1) - if (conflicts.length) throw new SlackSearchIdentityError('identity_conflict') + .limit(LINKED_IDENTITY_SCAN_LIMIT) + const own = linked.filter((row) => row.userId === userId) + if ( + linked.some((row) => row.subjectId === slackUserId && row.userId !== userId) || + (own.length > 0 && !own.some((row) => row.subjectId === slackUserId)) + ) + throw new SlackSearchIdentityError('identity_conflict') return userId } diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index 3f8b2bcc6f7..6f665851c14 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -166,7 +166,7 @@ export async function queryChunks( const page = keysetPage(keys, rows as ChunkData[], limit) - logger.info(`[${requestId}] Retrieved ${page.data.length} chunks for document ${documentId}`) + logger.info(`Retrieved ${page.data.length} chunks for document ${documentId}`, { requestId }) return { chunks: page.data, @@ -189,7 +189,7 @@ export async function createChunk( workspaceId?: string | null, secretProvenance?: DurableSecretProvenance ): Promise { - logger.info(`[${requestId}] Generating embedding for manual chunk`) + logger.info('Generating embedding for manual chunk', { requestId }) const kbRow = await db .select({ embeddingModel: knowledgeBase.embeddingModel, @@ -347,7 +347,7 @@ export async function createChunk( } as ChunkData }) - logger.info(`[${requestId}] Created chunk ${chunkId} in document ${documentId}`) + logger.info(`Created chunk ${chunkId} in document ${documentId}`, { requestId }) return newChunk } @@ -362,7 +362,8 @@ export async function batchChunkOperation( requestId: string ): Promise { logger.info( - `[${requestId}] Starting batch ${operation} operation on ${chunkIds.length} chunks for document ${documentId}` + `Starting batch ${operation} operation on ${chunkIds.length} chunks for document ${documentId}`, + { requestId } ) const errors: string[] = [] @@ -435,7 +436,8 @@ export async function batchChunkOperation( } logger.info( - `[${requestId}] Batch ${operation} completed: ${successCount} chunks processed, ${errors.length} errors` + `Batch ${operation} completed: ${successCount} chunks processed, ${errors.length} errors`, + { requestId } ) return { @@ -503,7 +505,7 @@ export async function updateChunk( dimensions: toKbEmbeddingDimensions(kbEmbeddingRow.embeddingDimension), } - logger.info(`[${requestId}] Content changed, regenerating embedding for chunk ${chunkId}`) + logger.info(`Content changed, regenerating embedding for chunk ${chunkId}`, { requestId }) const { embeddings } = await generateEmbeddings([content], chunkEmbedding, workspaceId) regenerated = { vectorValues: embeddingVectorValues(chunkEmbedding.dimensions, embeddings[0]), @@ -604,9 +606,9 @@ export async function updateChunk( .where(eq(embedding.id, chunkId)) .limit(1) - logger.info( - `[${requestId}] Updated chunk: ${chunkId}${regenerated ? ' (regenerated embedding)' : ''}` - ) + logger.info(`Updated chunk: ${chunkId}${regenerated ? ' (regenerated embedding)' : ''}`, { + requestId, + }) return updatedChunk[0] as ChunkData }) @@ -658,7 +660,7 @@ export async function updateChunk( throw new Error(`Chunk ${chunkId} not found`) } - logger.info(`[${requestId}] Updated chunk: ${chunkId}`) + logger.info(`Updated chunk: ${chunkId}`, { requestId }) return updatedChunk[0] as ChunkData } @@ -705,5 +707,5 @@ export async function deleteChunk( .where(eq(document.id, documentId)) }) - logger.info(`[${requestId}] Deleted chunk: ${chunkId}`) + logger.info(`Deleted chunk: ${chunkId}`, { requestId }) } diff --git a/apps/sim/lib/knowledge/connectors/member-provisioning.ts b/apps/sim/lib/knowledge/connectors/member-provisioning.ts index 5a791842bec..f9602a23c57 100644 --- a/apps/sim/lib/knowledge/connectors/member-provisioning.ts +++ b/apps/sim/lib/knowledge/connectors/member-provisioning.ts @@ -22,6 +22,7 @@ import { sameResourceScope, } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { moreUsableAccount } from '@/lib/credential-groups/account-rules' import { type CredentialGroupCredentialListContext, loadScopedAccountsCredentialListContext, @@ -370,7 +371,14 @@ export async function resolveViewerConnectorMemberships(input: { ) ) - const credentialsByOption = new Map(rows.map((row) => [row.credentialGroupOptionId, row])) + const credentialsByOption = new Map() + for (const row of rows) { + const current = credentialsByOption.get(row.credentialGroupOptionId) + credentialsByOption.set( + row.credentialGroupOptionId, + moreUsableAccount(current, row, (account) => account.managedOauthStatus) + ) + } const enrollmentStatus = rows[0]?.enrollmentStatus ?? null for (const connector of memberConnectors) { if (!readyOptionIds.has(connector.credentialGroupOptionId)) continue diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 76e1fad02eb..6fcf9c9f905 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -820,7 +820,8 @@ async function resolveAccessToken( if (revocationError && connector.credentialId) { throw new ConnectorCredentialRevokedError(connector.credentialId, revocationError) } - logger.error(`[${requestId}] Connector credential resolved no access token`, { + logger.error('Connector credential resolved no access token', { + requestId, credentialId: connector.credentialId, userId, authMode: connectorConfig.auth.mode, diff --git a/apps/sim/lib/knowledge/documents/processing-source.test.ts b/apps/sim/lib/knowledge/documents/processing-source.test.ts index feb358cc76e..d3357cd50aa 100644 --- a/apps/sim/lib/knowledge/documents/processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-source.test.ts @@ -643,7 +643,8 @@ describe('processDocumentAsync write guards', () => { ) ).rejects.toBe(databaseError) - expect(mockLogError).toHaveBeenCalledWith('[document-1] Failed to insert embedding batch', { + expect(mockLogError).toHaveBeenCalledWith('Failed to insert embedding batch', { + documentId: 'document-1', knowledgeBaseId: 'knowledge-base-1', operation: 'embedding.insert', batchNumber: 1, diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 799de16ee8f..c7b5f73ca2c 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -336,7 +336,8 @@ async function assertKnowledgeBaseFileUrlsOwnership( for (const key of keys) { const binding = bindingByKey.get(key) if (!binding || binding.workspaceId !== kbWorkspaceId) { - logger.warn(`[${requestId}] Rejected document referencing unowned knowledge-base file`, { + logger.warn('Rejected document referencing unowned knowledge-base file', { + requestId, storageKey: key, kbWorkspaceId, bindingWorkspaceId: binding?.workspaceId ?? null, @@ -651,9 +652,7 @@ function resolveDocumentTags( setTagValue(result, targetSlot, stringValue) } - logger.info(`[${requestId}] Set tag ${tagName} (${targetSlot})`, { - fieldType: actualFieldType, - }) + logger.info(`Set tag ${tagName} (${targetSlot})`, { requestId, fieldType: actualFieldType }) } return result @@ -1175,7 +1174,8 @@ async function bestEffortWithdrawDocumentsQueued( try { await clearDocumentsQueued(documentIds, queueToken, queuedAt) } catch (error) { - logger.warn(`[${queueToken}] Failed to withdraw queue ownership after ${reason}`, { + logger.warn(`Failed to withdraw queue ownership after ${reason}`, { + queueToken, error: getErrorMessage(error), }) } @@ -1272,7 +1272,8 @@ export async function processDocumentsWithQueue( } if (queuedDocuments.length === 0) { - logger.info(`[${requestId}] No documents were eligible for a new processing dispatch`, { + logger.info('No documents were eligible for a new processing dispatch', { + requestId, acceptedWithoutDispatch, unresolved, }) @@ -1300,10 +1301,10 @@ export async function processDocumentsWithQueue( }) const useTrigger = isTriggerAvailable() - logger.info( - `[${requestId}] Dispatching background processing for ${jobPayloads.length} documents`, - { backend: useTrigger ? 'trigger-dev' : 'direct' } - ) + logger.info(`Dispatching background processing for ${jobPayloads.length} documents`, { + requestId, + backend: useTrigger ? 'trigger-dev' : 'direct', + }) let dispatchedIds: Set try { @@ -1320,9 +1321,9 @@ export async function processDocumentsWithQueue( throw error } - logger.info( - `[${requestId}] Document dispatch complete: ${dispatchedIds.size}/${jobPayloads.length} accepted` - ) + logger.info(`Document dispatch complete: ${dispatchedIds.size}/${jobPayloads.length} accepted`, { + requestId, + }) /** * Refund every newly owned generation that provably failed before claiming @@ -1392,14 +1393,15 @@ async function dispatchViaBatchTrigger( batchIds.push(result.batchId) for (const payload of chunk) dispatchedIds.add(payload.documentId) } catch (error) { - logger.error(`[${requestId}] Failed to batchTrigger ${chunk.length} document jobs`, { + logger.error(`Failed to batchTrigger ${chunk.length} document jobs`, { + requestId, error: getErrorMessage(error), }) undispatched.push(...chunk) } } if (batchIds.length > 0) { - logger.info(`[${requestId}] Trigger.dev batches dispatched`, { batchIds }) + logger.info('Trigger.dev batches dispatched', { requestId, batchIds }) } /** @@ -1419,13 +1421,14 @@ async function dispatchViaBatchTrigger( const swept = undispatched.length - fallback.length if (swept > 0) { logger.warn( - `[${requestId}] Leaving ${swept} backfill documents for the stuck-document sweep after failed enqueue` + `Leaving ${swept} backfill documents for the stuck-document sweep after failed enqueue`, + { requestId } ) } if (fallback.length > 0) { - logger.warn( - `[${requestId}] Processing ${fallback.length} documents in-process after failed enqueue` - ) + logger.warn(`Processing ${fallback.length} documents in-process after failed enqueue`, { + requestId, + }) const directlyDispatchedIds = await dispatchInProcess(fallback, requestId, executionContext) for (const documentId of directlyDispatchedIds) dispatchedIds.add(documentId) } @@ -1574,13 +1577,15 @@ async function dispatchInProcess( return acceptedByLiveGeneration } catch (error) { if (isPermanentDocumentProcessingError(error) || error instanceof OcrRequestRejectedError) { - logger.warn(`[${requestId}] Document processing reached an expected terminal state`, { + logger.warn('Document processing reached an expected terminal state', { + requestId, code: error.code, }) return true } if (isEmbeddingQuotaExhaustion(error)) { - logger.warn(`[${requestId}] Embedding quota is exhausted; continuation scheduled`, { + logger.warn('Embedding quota is exhausted; continuation scheduled', { + requestId, documentId: p.documentId, quotaRetryCount: p.quotaRetryCount ?? 0, }) @@ -1590,7 +1595,8 @@ async function dispatchInProcess( getProviderCapacityDeferral(error) || error instanceof ProviderCapacityContinuationExhaustedError ) { - logger.warn(`[${requestId}] Provider capacity interrupted document processing`, { + logger.warn('Provider capacity interrupted document processing', { + requestId, documentId: p.documentId, providerRetryCount: p.providerRetryCount ?? 0, outcome: @@ -1601,7 +1607,8 @@ async function dispatchInProcess( return true } if (isBYOKEmbeddingCredentialRejection(error)) { - logger.warn(`[${requestId}] Customer-managed embedding credentials were rejected`, { + logger.warn('Customer-managed embedding credentials were rejected', { + requestId, documentId: p.documentId, status: error.status, }) @@ -1611,7 +1618,8 @@ async function dispatchInProcess( ? 'In-process document processing failed' : 'In-process document dispatch failed before claiming the document' const diagnostic = getConnectorFailureDiagnostic(error) - logger.error(`[${requestId}] ${message}`, { + logger.error(message, { + requestId, documentId: p.documentId, error: diagnostic?.message ?? getErrorMessage(error), ...(diagnostic ? { diagnostic } : {}), @@ -1689,7 +1697,8 @@ export async function processDocumentAsync( let processingFilename = docData.filename try { attemptContext?.signal?.throwIfAborted() - logger.info(`[${documentId}] Starting document processing`, { + logger.info('Starting document processing', { + documentId, knowledgeBaseId, mimeType: docData.mimeType, fileSize: docData.fileSize, @@ -1777,7 +1786,8 @@ export async function processDocumentAsync( } if (contextRows.length === 0) { logger.warn( - `[${documentId}] Skipping document processing: document or knowledge base ${knowledgeBaseId} no longer exists` + `Skipping document processing: document or knowledge base ${knowledgeBaseId} no longer exists`, + { documentId } ) await db .update(document) @@ -1874,14 +1884,15 @@ export async function processDocumentAsync( if (claimed.length === 0) { logger.info( - `[${documentId}] Skipping document processing: superseded, already active, completed, archived, or deleted` + 'Skipping document processing: superseded, already active, completed, archived, or deleted', + { documentId } ) return { outcome: 'skipped', reason: 'not_claimed' } } attemptContext?.onClaimed?.() - logger.info(`[${documentId}] Status updated to 'processing', starting document processor`) + logger.info("Status updated to 'processing', starting document processor", { documentId }) const rawConfig = ctx.chunkingConfig as { maxSize?: number @@ -1922,7 +1933,7 @@ export async function processDocumentAsync( const usageGate = await checkIngestionUsageLimits(billingAttribution) if (usageGate.isExceeded) { - logger.warn(`[${documentId}] Usage limit reached — skipping document indexing`) + logger.warn('Usage limit reached — skipping document indexing', { documentId }) throw new UsageLimitDocumentProcessingError( usageGate.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.' ) @@ -1985,7 +1996,8 @@ export async function processDocumentAsync( outputTokens: visionUsage.outputTokens, }) } catch (billingError) { - logger.error(`[${documentId}] Failed to record image extraction usage`, { + logger.error('Failed to record image extraction usage', { + documentId, error: billingError, }) } @@ -2043,8 +2055,9 @@ export async function processDocumentAsync( logger.info( prepared?.vectors - ? `[${documentId}] Indexing ${processed.chunks.length} chunks with their bundled vectors` - : `[${documentId}] Document parsed successfully, generating embeddings for ${processed.chunks.length} chunks` + ? `Indexing ${processed.chunks.length} chunks with their bundled vectors` + : `Document parsed successfully, generating embeddings for ${processed.chunks.length} chunks`, + { documentId } ) const chunkTexts = processed.chunks.map((chunk) => chunk.text) @@ -2074,16 +2087,16 @@ export async function processDocumentAsync( const batchSize = LARGE_DOC_CONFIG.MAX_EMBEDDING_BATCH const totalBatches = Math.ceil(chunkTexts.length / batchSize) - logger.info(`[${documentId}] Generating embeddings in ${totalBatches} batches`) + logger.info(`Generating embeddings in ${totalBatches} batches`, { documentId }) for (let i = 0; i < chunkTexts.length; i += batchSize) { signal.throwIfAborted() const batch = chunkTexts.slice(i, i + batchSize) const batchNum = Math.floor(i / batchSize) + 1 - logger.info( - `[${documentId}] Processing embedding batch ${batchNum}/${totalBatches}` - ) + logger.info(`Processing embedding batch ${batchNum}/${totalBatches}`, { + documentId, + }) const { embeddings: batchEmbeddings, billableTokens: batchBillableTokens, @@ -2122,9 +2135,9 @@ export async function processDocumentAsync( ) } - logger.info( - `[${documentId}] Embeddings generated, creating embedding records with tags` - ) + logger.info('Embeddings generated, creating embedding records with tags', { + documentId, + }) const chunkProvenances = processed.chunks.map((chunk) => documentSecretContext.tracked @@ -2257,14 +2270,15 @@ export async function processDocumentAsync( batches.push(embeddingRecords.slice(i, i + insertBatchSize)) } - logger.info(`[${documentId}] Inserting ${embeddingRecords.length} embeddings`) + logger.info(`Inserting ${embeddingRecords.length} embeddings`, { documentId }) for (const [batchIndex, batch] of batches.entries()) { signal.throwIfAborted() const insertStartedAt = Date.now() try { await tx.insert(embedding).values(batch) } catch (error) { - logger.error(`[${documentId}] Failed to insert embedding batch`, { + logger.error('Failed to insert embedding batch', { + documentId, knowledgeBaseId, operation: 'embedding.insert', batchNumber: batchIndex + 1, @@ -2353,12 +2367,12 @@ export async function processDocumentAsync( }) if (!processingCommitted) { - logger.info(`[${documentId}] Discarded output from an obsolete processing attempt`) + logger.info('Discarded output from an obsolete processing attempt', { documentId }) return { outcome: 'skipped', reason: 'superseded' } } const processingTime = Date.now() - startTime - logger.info(`[${documentId}] Successfully processed document in ${processingTime}ms`) + logger.info(`Successfully processed document in ${processingTime}ms`, { documentId }) if (billableEmbeddingTokens > 0) { try { @@ -2408,12 +2422,12 @@ export async function processDocumentAsync( }) } else { logger.warn( - `[${documentId}] Embedding model "${embeddingModelName}" has no pricing entry — billing skipped`, - { billableEmbeddingTokens, embeddingModelName } + `Embedding model "${embeddingModelName}" has no pricing entry — billing skipped`, + { documentId, billableEmbeddingTokens, embeddingModelName } ) } } catch (billingError) { - logger.error(`[${documentId}] Failed to record embedding usage`, { error: billingError }) + logger.error('Failed to record embedding usage', { documentId, error: billingError }) } } @@ -2465,6 +2479,7 @@ export async function processDocumentAsync( ? failureDiagnostic.message : getErrorMessage(recordedError, 'Unknown error') const logContext = { + documentId, errorType: toError(recordedError).name, ...(failureDiagnostic ? { diagnostic: failureDiagnostic } : {}), knowledgeBaseId, @@ -2479,8 +2494,8 @@ export async function processDocumentAsync( : {}), } const logMessage = deferredUntil - ? `[${documentId}] Deferred document processing after ${processingTime}ms:` - : `[${documentId}] Failed to process document after ${processingTime}ms:` + ? `Deferred document processing after ${processingTime}ms:` + : `Failed to process document after ${processingTime}ms:` if ( (embeddingQuotaExhausted && !quotaContinuationFailed) || deferredUntil || @@ -2792,7 +2807,7 @@ export async function createDocumentRecords( } } catch (error) { if (error instanceof SyntaxError) { - logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) + logger.warn('Failed to parse documentTagsData for bulk document:', error, { requestId }) } else { throw error } @@ -2882,7 +2897,8 @@ export async function createDocumentRecords( ) } logger.info( - `[${requestId}] Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}` + `Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}`, + { requestId } ) await tx @@ -3150,7 +3166,8 @@ export async function getDocuments( const hasMore = offset + limit < total logger.info( - `[${requestId}] Retrieved ${documents.length} documents (${offset}-${offset + documents.length} of ${total}) for knowledge base ${knowledgeBaseId}` + `Retrieved ${documents.length} documents (${offset}-${offset + documents.length} of ${total}) for knowledge base ${knowledgeBaseId}`, + { requestId } ) return { @@ -3356,7 +3373,7 @@ export async function createSingleDocument( } } catch (error) { if (error instanceof SyntaxError) { - logger.warn(`[${requestId}] Failed to parse documentTagsData:`, error) + logger.warn('Failed to parse documentTagsData:', error, { requestId }) } else { throw error } @@ -3535,7 +3552,7 @@ export async function createSingleDocument( ) } - logger.info(`[${requestId}] Document created: ${documentId} in knowledge base ${knowledgeBaseId}`) + logger.info(`Document created: ${documentId} in knowledge base ${knowledgeBaseId}`, { requestId }) return newDocument as { id: string @@ -3627,7 +3644,8 @@ export async function bulkDocumentOperation( }> }> { logger.info( - `[${requestId}] Starting bulk ${operation} operation on ${documentIds.length} documents in knowledge base ${knowledgeBaseId}` + `Starting bulk ${operation} operation on ${documentIds.length} documents in knowledge base ${knowledgeBaseId}`, + { requestId } ) const candidateConditions = [ @@ -3662,7 +3680,8 @@ export async function bulkDocumentOperation( if (documentsToUpdate.length !== documentIds.length) { logger.warn( - `[${requestId}] Some documents not found or don't belong to knowledge base. Requested: ${documentIds.length}, Found: ${documentsToUpdate.length}` + `Some documents not found or don't belong to knowledge base. Requested: ${documentIds.length}, Found: ${documentsToUpdate.length}`, + { requestId } ) } @@ -3703,7 +3722,8 @@ export async function bulkDocumentOperation( const successCount = updateResult.length logger.info( - `[${requestId}] Bulk ${operation} operation completed: ${successCount} documents updated in knowledge base ${knowledgeBaseId}` + `Bulk ${operation} operation completed: ${successCount} documents updated in knowledge base ${knowledgeBaseId}`, + { requestId } ) return { @@ -3729,7 +3749,8 @@ export async function bulkDocumentOperationByFilter( }> }> { logger.info( - `[${requestId}] Starting bulk ${operation} operation on all documents (filter: ${enabledFilter || 'all'}) in knowledge base ${knowledgeBaseId}` + `Starting bulk ${operation} operation on all documents (filter: ${enabledFilter || 'all'}) in knowledge base ${knowledgeBaseId}`, + { requestId } ) const whereConditions = [ @@ -3778,7 +3799,8 @@ export async function bulkDocumentOperationByFilter( const successCount = updateResult.length logger.info( - `[${requestId}] Bulk ${operation} by filter completed: ${successCount} documents updated in knowledge base ${knowledgeBaseId}` + `Bulk ${operation} by filter completed: ${successCount} documents updated in knowledge base ${knowledgeBaseId}`, + { requestId } ) return { @@ -3802,10 +3824,11 @@ export async function markDocumentAsFailedTimeout( if (result.success) { logger.info( - `[${requestId}] Marked document ${documentId} as failed due to dead process (processing time: ${Math.round(result.processingDuration / 1000)}s)` + `Marked document ${documentId} as failed due to dead process (processing time: ${Math.round(result.processingDuration / 1000)}s)`, + { requestId } ) } else { - logger.info(`[${requestId}] Did not time out document ${documentId} because its claim changed`) + logger.info(`Did not time out document ${documentId} because its claim changed`, { requestId }) } return result @@ -3932,7 +3955,7 @@ export async function retryDocumentProcessing( status: 'failed', message: 'Source content could not be downloaded. Sync the connector to retry.', } - logger.info(`[${requestId}] Document retry skipped, already queued: ${documentId}`) + logger.info(`Document retry skipped, already queued: ${documentId}`, { requestId }) return { success: true, status: 'pending', @@ -3984,7 +4007,7 @@ export async function retryDocumentProcessing( } } - logger.info(`[${requestId}] Document retry initiated: ${documentId}`) + logger.info(`Document retry initiated: ${documentId}`, { requestId }) return { success: true, @@ -4196,7 +4219,7 @@ export async function updateDocument( throw new Error(`Document ${documentId} not found`) } - logger.info(`[${requestId}] Document updated: ${documentId}`) + logger.info(`Document updated: ${documentId}`, { requestId }) return { id: doc.id, @@ -4255,7 +4278,8 @@ async function excludeConnectorDocuments( .returning({ id: document.id }) if (updated.length > 0) { - logger.info(`[${requestId}] Excluded ${updated.length} connector-backed document(s)`, { + logger.info(`Excluded ${updated.length} connector-backed document(s)`, { + requestId, documentIds: updated.map((doc) => doc.id), }) } @@ -4612,7 +4636,8 @@ async function hardDeleteDocumentBatch( }) }) - logger.info(`[${requestId}] Hard deleted ${deletedDocs.length} documents`, { + logger.info(`Hard deleted ${deletedDocs.length} documents`, { + requestId, documentIds: deletedDocs.map((doc) => doc.id), }) @@ -4676,7 +4701,8 @@ async function excludeConnectorKnowledgeDocuments( ) .returning({ id: document.id }) if (updated.length > 0) { - logger.info(`[${requestId}] Excluded ${updated.length} connector-backed document(s)`, { + logger.info(`Excluded ${updated.length} connector-backed document(s)`, { + requestId, documentIds: updated.map((row) => row.id), knowledgeBaseId, }) diff --git a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts index 8cde306a04b..4e8c7099594 100644 --- a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts @@ -3,6 +3,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { createPersonalApiKeyPrincipal } from '@sim/testing/factories/principal.factory' import { apiServerRoutesMock } from '@sim/testing/mocks/api-server-routes.mock' import { urlsMockFns } from '@sim/testing/mocks/urls.mock' +import { toArray, toRecord } from '@sim/utils/object' import { NextRequest } from 'next/server' import { describe, expect, it, vi } from 'vitest' @@ -39,6 +40,7 @@ describe('Search MCP protocol', () => { content: 'Evidence', chunkIndex: 0, similarity: 0.5, + queryIndexes: [0], } mocks.liveSearch.mockResolvedValue({ results: [row], @@ -74,12 +76,19 @@ describe('Search MCP protocol', () => { expect(searchSchema?.properties).toHaveProperty('nativeQueries') expect(readSchema?.properties).toHaveProperty('startOffset') expect(readSchema?.properties).not.toHaveProperty('url') - const search = await client.callTool({ name: 'search', arguments: { query: 'release' } }) + const search = await client.callTool({ + name: 'search', + arguments: { + query: 'release', + nativeQueries: [{ provider: 'google_drive', query: "name contains 'release'" }], + }, + }) expect(search.isError).not.toBe(true) - expect(search.content).toEqual([{ type: 'text', text: expect.stringContaining(documentId) }]) - expect(mocks.liveSearch).toHaveBeenCalledWith( - expect.objectContaining({ input: expect.objectContaining({ topK: 30 }) }) - ) + const searchContent = toRecord(toArray(search.content)[0]) + if (typeof searchContent.text !== 'string') throw new Error('Missing search payload') + expect(JSON.parse(searchContent.text)).toMatchObject({ + results: [{ documentId, queryIndexes: [0] }], + }) const read = await client.callTool({ name: 'read_document', arguments: { documentId } }) expect(read.isError).not.toBe(true) expect(read.content).toEqual([ diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index d7721f78117..3edfc867937 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -162,6 +162,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe sourceDate: row.sourceDate, sourceContainerName: row.sourceContainerName, sourceContainerUrl: row.sourceContainerUrl, + ...(row.queryIndexes ? { queryIndexes: row.queryIndexes } : {}), connectorType: row.connectorType, content: row.content, chunkIndex: row.chunkIndex, diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.ts b/apps/sim/lib/knowledge/orchestration/connector-access.ts index 2a71b798312..0ad4ff6a248 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.ts @@ -280,7 +280,7 @@ export async function performUpdateKnowledgeConnectorAccess( ) .returning() if (!updated) return fail('Connector changed; retry the request', 'conflict') - logger.info(`[${requestId}] Re-enabled member sync on connector ${connectorId}`) + logger.info(`Re-enabled member sync on connector ${connectorId}`, { requestId }) const connector = withoutSecret(updated) if (updated.status !== 'paused') { await dispatchMemberSyncBestEffort(connectorId, params, requestId, now) @@ -382,9 +382,7 @@ export async function performUpdateKnowledgeConnectorAccess( }) const updated = await releaseSwitchLease(connectorId, switchId, previousStatus) if (!updated) throw new SwitchLeaseLostError() - logger.info(`[${requestId}] Switched connector ${connectorId} to members mode`, { - rewritten, - }) + logger.info(`Switched connector ${connectorId} to members mode`, { requestId, rewritten }) const connector = withoutSecret(updated) if (previousStatus !== 'paused') { await dispatchMemberSyncBestEffort(connectorId, params, requestId, flippedAt) @@ -418,7 +416,8 @@ export async function performUpdateKnowledgeConnectorAccess( params.userId ) ).catch((undoError) => { - logger.error(`[${requestId}] Failed to undo the grant of an abandoned switch`, { + logger.error('Failed to undo the grant of an abandoned switch', { + requestId, connectorId, error: getErrorMessage(undoError), }) @@ -500,7 +499,8 @@ export async function performUpdateKnowledgeConnectorAccess( }, params.userId ).catch((error) => { - logger.error(`[${requestId}] Failed to revoke the grant after leaving members mode`, { + logger.error('Failed to revoke the grant after leaving members mode', { + requestId, connectorId, error: getErrorMessage(error), }) @@ -510,7 +510,8 @@ export async function performUpdateKnowledgeConnectorAccess( accessRewritePending: !rewritten, }) if (!updated) throw new SwitchLeaseLostError() - logger.info(`[${requestId}] Switched connector ${connectorId} to ${target.accessMode} mode`, { + logger.info(`Switched connector ${connectorId} to ${target.accessMode} mode`, { + requestId, rewritten, }) const connector = withoutSecret(updated) @@ -525,7 +526,8 @@ export async function performUpdateKnowledgeConnectorAccess( return fail('Connector changed during the switch; retry the request', 'conflict') } await releaseSwitchLease(connectorId, switchId, previousStatus).catch((releaseError) => { - logger.error(`[${requestId}] Failed to release the access switch lease`, { + logger.error('Failed to release the access switch lease', { + requestId, connectorId, error: releaseError, }) @@ -554,10 +556,11 @@ async function dispatchMemberSyncBestEffort( requireRunnable: true, }) if (!dispatch.queued) { - logger.warn(`[${requestId}] Member sync after the switch was not queued: ${dispatch.reason}`) + logger.warn(`Member sync after the switch was not queued: ${dispatch.reason}`, { requestId }) } } catch (error) { - logger.error(`[${requestId}] Failed to dispatch the member sync after the switch`, { + logger.error('Failed to dispatch the member sync after the switch', { + requestId, connectorId, error, }) @@ -579,12 +582,9 @@ async function dispatchContentSyncBestEffort( requireRunnable: true, }) if (!dispatch.queued) { - logger.warn(`[${requestId}] Sync after the switch was not queued: ${dispatch.reason}`) + logger.warn(`Sync after the switch was not queued: ${dispatch.reason}`, { requestId }) } } catch (error) { - logger.error(`[${requestId}] Failed to dispatch the sync after the switch`, { - connectorId, - error, - }) + logger.error('Failed to dispatch the sync after the switch', { requestId, connectorId, error }) } } diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index d157987d8a0..058439ab50f 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -417,7 +417,7 @@ export async function performCreateKnowledgeConnector( newTagSlots = mapping for (const name of skippedTags) { - logger.warn(`[${requestId}] No available slots for "${name}"`) + logger.warn(`No available slots for "${name}"`, { requestId }) } if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { @@ -588,7 +588,8 @@ export async function performCreateKnowledgeConnector( { workspaceId, credentialGroupId: membersBinding.credentialGroupId, connectorId }, params.userId ).catch((revokeError) => { - logger.error(`[${requestId}] Failed to revoke the grant of an uncreated connector`, { + logger.error('Failed to revoke the grant of an uncreated connector', { + requestId, connectorId, error: revokeError, }) @@ -606,7 +607,7 @@ export async function performCreateKnowledgeConnector( return { success: true, connector: withoutSecret(created), reused: true } } - logger.info(`[${requestId}] Created connector ${connectorId} for KB ${kb.id}`) + logger.info(`Created connector ${connectorId} for KB ${kb.id}`, { requestId }) if (params.recordProductAnalytics !== false) { captureServerEvent( @@ -669,16 +670,15 @@ export async function performCreateKnowledgeConnector( : await (await loadDispatchSync())(connectorId, { billingAttribution, requestId }) if (!dispatch.queued) { initialSyncQueued = false - logger.warn( - `[${requestId}] Initial sync for connector ${connectorId} was not queued: ${dispatch.reason}` - ) + logger.warn(`Initial sync for connector ${connectorId} was not queued: ${dispatch.reason}`, { + requestId, + }) } } catch (error) { initialSyncQueued = false - logger.error( - `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, - error - ) + logger.error(`Failed to dispatch initial sync for connector ${connectorId}`, error, { + requestId, + }) } return { success: true, connector: withoutSecret(created), initialSyncQueued } @@ -1119,13 +1119,11 @@ export async function performUpdateKnowledgeConnector( requireRunnable: true, }) } catch (error) { - logger.error( - `[${requestId}] Saved connector; member sync remains due after dispatch failed`, - { - connectorId, - error, - } - ) + logger.error('Saved connector; member sync remains due after dispatch failed', { + requestId, + connectorId, + error, + }) } } @@ -1138,7 +1136,8 @@ export async function performUpdateKnowledgeConnector( requireRunnable: true, }) } catch (error) { - logger.error(`[${requestId}] Saved connector; sync remains due after dispatch failed`, { + logger.error('Saved connector; sync remains due after dispatch failed', { + requestId, connectorId, error, }) @@ -1356,7 +1355,8 @@ export async function performDeleteKnowledgeConnector( }) } catch (error) { if (['55P03', '57014', '40P01'].includes(getPostgresErrorCode(error) ?? '')) { - logger.warn(`[${requestId}] Connector removal could not acquire or finish its transaction`, { + logger.warn('Connector removal could not acquire or finish its transaction', { + requestId, connectorId, error, }) @@ -1382,7 +1382,8 @@ export async function performDeleteKnowledgeConnector( }, params.userId ).catch((error) => { - logger.error(`[${requestId}] Failed to revoke the detached connector's credential access`, { + logger.error("Failed to revoke the detached connector's credential access", { + requestId, connectorId, error, }) @@ -1390,7 +1391,8 @@ export async function performDeleteKnowledgeConnector( } logger.info( - `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` + `Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`, + { requestId } ) if (params.recordProductAnalytics !== false) { @@ -1512,7 +1514,8 @@ export async function performSyncKnowledgeConnector( ) } logger.info( - `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` + `Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}`, + { requestId } ) /** @@ -1545,16 +1548,15 @@ export async function performSyncKnowledgeConnector( * with an audit and a product event behind it. */ if (!dispatch.queued) { - logger.warn( - `[${requestId}] Manual sync for connector ${connectorId} was not queued: ${dispatch.reason}` - ) + logger.warn(`Manual sync for connector ${connectorId} was not queued: ${dispatch.reason}`, { + requestId, + }) return fail(dispatch.reason ?? 'Sync could not be queued', 'conflict') } } catch (error) { - logger.error( - `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, - error - ) + logger.error(`Failed to dispatch manual sync for connector ${connectorId}`, error, { + requestId, + }) return classifyKnowledgeFailure(error, requestId, `Sync connector ${connectorId}`) } diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index 98e9faecb43..eacab19bce2 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -339,7 +339,7 @@ export async function performUploadKnowledgeDocuments( ) } - logger.info(`[${requestId}] Starting controlled async processing of ${created.length} documents`) + logger.info(`Starting controlled async processing of ${created.length} documents`, { requestId }) void dispatchDocumentProcessing({ documents: created, @@ -391,9 +391,9 @@ export async function performDeleteKnowledgeDocument( return classifyKnowledgeFailure(error, requestId, `Delete document ${document.id}`) } - logger.info( - `[${requestId}] Deleted document ${document.id} from knowledge base ${knowledgeBase.id}` - ) + logger.info(`Deleted document ${document.id} from knowledge base ${knowledgeBase.id}`, { + requestId, + }) recordAudit({ workspaceId: knowledgeBase.workspaceId, diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts index 985f80e566a..66a07ab65ab 100644 --- a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts @@ -82,7 +82,7 @@ export async function performCreateKnowledgeBase( return classifyKnowledgeFailure(error, requestId, `Create knowledge base "${name}"`) } - logger.info(`[${requestId}] Created knowledge base ${created.id} for user ${params.userId}`) + logger.info(`Created knowledge base ${created.id} for user ${params.userId}`, { requestId }) PlatformEvents.knowledgeBaseCreated({ knowledgeBaseId: created.id, @@ -170,7 +170,7 @@ export async function performUpdateKnowledgeBase( return classifyKnowledgeFailure(error, requestId, `Update knowledge base ${knowledgeBaseId}`) } - logger.info(`[${requestId}] Updated knowledge base ${knowledgeBaseId}`) + logger.info(`Updated knowledge base ${knowledgeBaseId}`, { requestId }) recordAudit({ // The destination workspace when this update moved it, so the audit row @@ -229,7 +229,7 @@ export async function performDeleteKnowledgeBase( return classifyKnowledgeFailure(error, requestId, `Delete knowledge base ${knowledgeBase.id}`) } - logger.info(`[${requestId}] Deleted knowledge base ${knowledgeBase.id}`) + logger.info(`Deleted knowledge base ${knowledgeBase.id}`, { requestId }) PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: knowledgeBase.id }) diff --git a/apps/sim/lib/knowledge/orchestration/restore.ts b/apps/sim/lib/knowledge/orchestration/restore.ts index be4881743de..b456d0a56ef 100644 --- a/apps/sim/lib/knowledge/orchestration/restore.ts +++ b/apps/sim/lib/knowledge/orchestration/restore.ts @@ -78,7 +78,7 @@ export async function performRestoreKnowledgeBase( return classifyKnowledgeFailure(error, requestId, `Restore knowledge base ${knowledgeBaseId}`) } - logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) + logger.info(`Restored knowledge base ${knowledgeBaseId}`, { requestId }) if (params.recordSemanticAudit !== false) { recordAudit({ diff --git a/apps/sim/lib/knowledge/orchestration/shared.ts b/apps/sim/lib/knowledge/orchestration/shared.ts index 2c2581deee7..c8f9a8aa8d0 100644 --- a/apps/sim/lib/knowledge/orchestration/shared.ts +++ b/apps/sim/lib/knowledge/orchestration/shared.ts @@ -70,7 +70,7 @@ export function classifyKnowledgeFailure( if (classified) { return fail(classified.message, classified.code) } - logger.error(`[${requestId}] ${operation} failed`, { error }) + logger.error(`${operation} failed`, { requestId, error }) return fail(toError(error).message, 'internal') } diff --git a/apps/sim/lib/knowledge/search/connection-target.ts b/apps/sim/lib/knowledge/search/connection-target.ts index 040224f912a..1541998f308 100644 --- a/apps/sim/lib/knowledge/search/connection-target.ts +++ b/apps/sim/lib/knowledge/search/connection-target.ts @@ -9,9 +9,14 @@ export const searchConnectionTargetSchema = z connectorType: z.string().trim().min(1).max(100), credentialId: z.string().min(1).max(128).optional(), connectionMode: z.literal('live'), - optionId: z.string().min(1).max(128), + optionId: z.string().min(1).max(128).optional(), + savedProviderId: z.enum(['jama-service-account', 'flow-service-account']).optional(), }) .strict() + .refine( + (target) => Boolean(target.optionId) !== Boolean(target.savedProviderId), + 'Select exactly one connection method' + ) export type SearchConnectionTarget = z.infer @@ -50,7 +55,8 @@ export function searchConnectionPath(organizationId: string, target: SearchConne connectorType: target.connectorType, ...(target.credentialId ? { credentialId: target.credentialId } : {}), connectionMode: target.connectionMode, - optionId: target.optionId, + ...(target.optionId ? { optionId: target.optionId } : {}), + ...(target.savedProviderId ? { savedProviderId: target.savedProviderId } : {}), provider: target.provider, })}` } diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 37e86ebf637..c6b79188264 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -489,7 +489,7 @@ export async function createAuthorizedKnowledgeBase( throw error } - logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) + logger.info(`Created knowledge base: ${data.name} (${kbId})`, { requestId }) return { id: kbId, @@ -877,7 +877,7 @@ export async function updateKnowledgeBase( throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } - logger.info(`[${requestId}] Updated knowledge base: ${knowledgeBaseId}`) + logger.info(`Updated knowledge base: ${knowledgeBaseId}`, { requestId }) return toActiveKnowledgeBaseReference(updated) } @@ -1065,7 +1065,7 @@ export async function deleteKnowledgeBase( ) }) - logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) + logger.info(`Soft deleted knowledge base: ${knowledgeBaseId}`, { requestId }) } /** @@ -1213,7 +1213,7 @@ export async function restoreKnowledgeBase( } } - logger.info( - `[${requestId}] Restored knowledge base: ${knowledgeBaseId} as "${attemptedRestoreName}"` - ) + logger.info(`Restored knowledge base: ${knowledgeBaseId} as "${attemptedRestoreName}"`, { + requestId, + }) } diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index 03091a79567..523762ca1bc 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -504,7 +504,8 @@ export async function createOrUpdateTagDefinitionsBulk( }) logger.info( - `[${requestId}] Bulk tag definitions processed: ${created.length} created, ${updated.length} updated, ${errors.length} errors` + `Bulk tag definitions processed: ${created.length} created, ${updated.length} updated, ${errors.length} errors`, + { requestId } ) return { created, updated, errors } @@ -594,13 +595,13 @@ export async function cleanupUnusedTagDefinitions( .where(eq(knowledgeBaseTagDefinitions.id, def.id)) cleanedUp++ - logger.info( - `[${requestId}] Cleaned up unused tag definition: ${def.displayName} (${def.tagSlot})` - ) + logger.info(`Cleaned up unused tag definition: ${def.displayName} (${def.tagSlot})`, { + requestId, + }) } } - logger.info(`[${requestId}] Cleanup completed: ${cleanedUp} unused tag definitions removed`) + logger.info(`Cleanup completed: ${cleanedUp} unused tag definitions removed`, { requestId }) return cleanedUp } @@ -635,7 +636,7 @@ export async function deleteAllTagDefinitions( return definitions.length }) - logger.info(`[${requestId}] Deleted ${deletedCount} tag definitions for KB: ${knowledgeBaseId}`) + logger.info(`Deleted ${deletedCount} tag definitions for KB: ${knowledgeBaseId}`, { requestId }) return deletedCount } @@ -684,9 +685,9 @@ export async function deleteTagDefinition( const tagSlot = definition.tagSlot as string - logger.info( - `[${requestId}] Deleted tag definition with cleanup: ${definition.displayName} (${tagSlot})` - ) + logger.info(`Deleted tag definition with cleanup: ${definition.displayName} (${tagSlot})`, { + requestId, + }) return { tagSlot, @@ -739,7 +740,8 @@ async function insertTagDefinition( await dbInstance.insert(knowledgeBaseTagDefinitions).values(newDefinition) logger.info( - `[${requestId}] Created tag definition: ${data.displayName} -> ${data.tagSlot} in KB ${data.knowledgeBaseId}` + `Created tag definition: ${data.displayName} -> ${data.tagSlot} in KB ${data.knowledgeBaseId}`, + { requestId } ) return { @@ -810,7 +812,7 @@ export async function updateTagDefinition( } const updated = updatedRows[0] - logger.info(`[${requestId}] Updated tag definition: ${tagDefinitionId}`) + logger.info(`Updated tag definition: ${tagDefinitionId}`, { requestId }) return { ...updated, @@ -883,7 +885,7 @@ export async function getTagUsage( }) } - logger.info(`[${requestId}] Retrieved detailed tag usage for ${usage.length} definitions`) + logger.info(`Retrieved detailed tag usage for ${usage.length} definitions`, { requestId }) return usage } @@ -964,7 +966,7 @@ export async function getTagUsageStats( }) } - logger.info(`[${requestId}] Retrieved tag usage stats for ${stats.length} definitions`) + logger.info(`Retrieved tag usage stats for ${stats.length} definitions`, { requestId }) return stats } diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index dc205891752..4824fb92a26 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -877,7 +877,7 @@ export class LoggingSession { const deleted = skipLogCreation ? null : await this.findDeletedScope(error, workspaceId) if (deleted) throw deleted if (this.requestId) { - logger.error(`[${this.requestId}] Failed to start logging:`, error) + logger.error('Failed to start logging:', error, { requestId: this.requestId }) } throw error } @@ -1134,9 +1134,9 @@ export class LoggingSession { } if (this.requestId) { - logger.debug( - `[${this.requestId}] Completed error logging for execution ${this.executionId}` - ) + logger.debug(`Completed error logging for execution ${this.executionId}`, { + requestId: this.requestId, + }) } } catch (enhancedError) { this.completing = false @@ -1216,9 +1216,9 @@ export class LoggingSession { } if (this.requestId) { - logger.debug( - `[${this.requestId}] Completed cancelled logging for execution ${this.executionId}` - ) + logger.debug(`Completed cancelled logging for execution ${this.executionId}`, { + requestId: this.requestId, + }) } } catch (cancelError) { this.completing = false @@ -1316,9 +1316,9 @@ export class LoggingSession { } catch (_e) {} if (this.requestId) { - logger.debug( - `[${this.requestId}] Completed paused logging for execution ${this.executionId}` - ) + logger.debug(`Completed paused logging for execution ${this.executionId}`, { + requestId: this.requestId, + }) } } catch (pauseError) { this.completing = false @@ -1364,10 +1364,9 @@ export class LoggingSession { return false } if (this.requestId) { - logger.warn( - `[${this.requestId}] Logging start failed - falling back to minimal session:`, - error - ) + logger.warn('Logging start failed - falling back to minimal session:', error, { + requestId: this.requestId, + }) } // Fallback: create a minimal logging session without full workflow state @@ -1416,9 +1415,9 @@ export class LoggingSession { }) if (this.requestId) { - logger.debug( - `[${this.requestId}] Started minimal logging for execution ${this.executionId}` - ) + logger.debug(`Started minimal logging for execution ${this.executionId}`, { + requestId: this.requestId, + }) } return true } catch (fallbackError) { @@ -1428,7 +1427,9 @@ export class LoggingSession { return false } if (this.requestId) { - logger.error(`[${this.requestId}] Minimal logging start also failed:`, fallbackError) + logger.error('Minimal logging start also failed:', fallbackError, { + requestId: this.requestId, + }) } return false } @@ -1521,10 +1522,10 @@ export class LoggingSession { } catch (error) { if (error instanceof ExecutionScopeDeletedError) return this.endDeletedScopeRun(error) const errorMsg = toError(error).message - logger.warn( - `[${this.requestId || 'unknown'}] Complete failed for execution ${this.executionId}, attempting fallback`, - { error: errorMsg } - ) + logger.warn(`Complete failed for execution ${this.executionId}, attempting fallback`, { + requestId: this.requestId, + error: errorMsg, + }) await this.completeWithCostOnlyLog({ traceSpans: params.traceSpans, endedAt: params.endedAt, @@ -1551,8 +1552,8 @@ export class LoggingSession { if (error instanceof ExecutionScopeDeletedError) return this.endDeletedScopeRun(error) const errorMsg = toError(error).message logger.warn( - `[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`, - { error: errorMsg } + `CompleteWithError failed for execution ${this.executionId}, attempting fallback`, + { requestId: this.requestId, error: errorMsg } ) await this.completeWithCostOnlyLog({ traceSpans: params?.traceSpans, @@ -1585,8 +1586,8 @@ export class LoggingSession { if (error instanceof ExecutionScopeDeletedError) return this.endDeletedScopeRun(error) const errorMsg = toError(error).message logger.warn( - `[${this.requestId || 'unknown'}] CompleteWithCancellation failed for execution ${this.executionId}, attempting fallback`, - { error: errorMsg } + `CompleteWithCancellation failed for execution ${this.executionId}, attempting fallback`, + { requestId: this.requestId, error: errorMsg } ) await this.completeWithCostOnlyLog({ traceSpans: params?.traceSpans, @@ -1614,8 +1615,8 @@ export class LoggingSession { if (error instanceof ExecutionScopeDeletedError) return this.endDeletedScopeRun(error) const errorMsg = toError(error).message logger.warn( - `[${this.requestId || 'unknown'}] CompleteWithPause failed for execution ${this.executionId}, attempting fallback`, - { error: errorMsg } + `CompleteWithPause failed for execution ${this.executionId}, attempting fallback`, + { requestId: this.requestId, error: errorMsg } ) await this.completeWithCostOnlyLog({ traceSpans: params?.traceSpans, @@ -1716,7 +1717,7 @@ export class LoggingSession { if (markers !== null) void clearProgressMarkers(executionId) - logger.info(`[${requestId || 'unknown'}] Marked execution ${executionId} as failed`) + logger.info(`Marked execution ${executionId} as failed`, { requestId }) } catch (error) { logger.error(`Failed to mark execution ${executionId} as failed:`, { error: toError(error).message, @@ -1741,7 +1742,8 @@ export class LoggingSession { this.completing = true logger.warn( - `[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback` + `Logging completion failed for execution ${this.executionId} - attempting cost-only fallback`, + { requestId: this.requestId } ) try { @@ -1769,23 +1771,21 @@ export class LoggingSession { this.completed = true - logger.info( - `[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}` - ) + logger.info(`Cost-only fallback succeeded for execution ${this.executionId}`, { + requestId: this.requestId, + }) } catch (fallbackError) { this.completing = false if (fallbackError instanceof ExecutionScopeDeletedError) { return this.endDeletedScopeRun(fallbackError) } this.completionAttemptFailed = true - logger.error( - `[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`, - { - error: toError(fallbackError).message, - cause: describeError(fallbackError), - retryable: isRetryableInfrastructureError(fallbackError), - } - ) + logger.error(`Cost-only fallback also failed for execution ${this.executionId}:`, { + requestId: this.requestId, + error: toError(fallbackError).message, + cause: describeError(fallbackError), + retryable: isRetryableInfrastructureError(fallbackError), + }) } } } diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index 0708d031709..d9c0d6d5233 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -35,6 +35,12 @@ function requireMcpToolSchema(inputSchema: unknown): McpToolSchema { return inputSchema as McpToolSchema } +/** Names the person, plus the account once a connection knows it, so a person's two read apart. */ +function connectionName(row: { serverName: string; email: string; accountLabel: string }) { + const known = row.accountLabel !== row.serverName && row.accountLabel !== row.email + return `${row.serverName} — ${row.email}${known ? ` · ${row.accountLabel}` : ''}` +} + export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase({ operation: mcpServerOperations.listManagedConnections, resolveContext: ({ input }: { input: { workspaceId: string } }) => @@ -96,6 +102,7 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase serverDescription: mcpServers.description, managedConnectorId: mcpServers.managedConnectorId, email: credentialGroupEnrollment.email, + accountLabel: credential.displayName, toolSnapshotBytes: sql`COALESCE(octet_length(${credential.mcpTools}::text), 0)`.mapWith(Number), createdAt: credential.createdAt, @@ -177,7 +184,7 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase canonicalServerId: row.serverId, canonicalServerName: row.serverName, workspaceId: context.workspaceId, - name: `${row.serverName} — ${row.email}`, + name: connectionName(row), ...(row.serverDescription ? { description: row.serverDescription } : {}), transport: 'streamable-http' as const, authType: 'oauth' as const, @@ -198,7 +205,7 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase ...(outputSchema ? { outputSchema } : {}), serverId: row.id, canonicalServerId: row.serverId, - serverName: `${row.serverName} — ${row.email}`, + serverName: connectionName(row), managedConnectorId: row.managedConnectorId, } }) diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index c7cfbaae666..f87b1e8952b 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -234,10 +234,12 @@ export class McpClient { if (this.onToolsChanged) { this.client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (!this.isConnected) return - logger.info(`[${this.config.name}] Received tools/list_changed notification`) + logger.info('Received tools/list_changed notification', { serverName: this.config.name }) this.onToolsChanged?.(this.config.id) }) - logger.info(`[${this.config.name}] Registered tools/list_changed notification handler`) + logger.info('Registered tools/list_changed notification handler', { + serverName: this.config.name, + }) } logger.info(`Successfully connected to MCP server: ${this.config.name}`, { @@ -507,16 +509,16 @@ export class McpClient { } try { - logger.info(`[${this.config.name}] Sending ping to server`) + logger.info('Sending ping to server', { serverName: this.config.name }) // Bound the ping so a half-open connection (no FIN/RST, so `onclose` never // fires) is detected quickly instead of stalling on the SDK's 60s default. const response = await this.client.ping( timeoutMs !== undefined ? { timeout: timeoutMs } : undefined ) - logger.info(`[${this.config.name}] Ping successful`) + logger.info('Ping successful', { serverName: this.config.name }) return response } catch (error) { - logger.warn(`[${this.config.name}] Ping failed:`, error) + logger.warn('Ping failed:', error, { serverName: this.config.name }) throw error } } diff --git a/apps/sim/lib/mcp/connection-manager.ts b/apps/sim/lib/mcp/connection-manager.ts index 5c64310dcba..a47d10f3967 100644 --- a/apps/sim/lib/mcp/connection-manager.ts +++ b/apps/sim/lib/mcp/connection-manager.ts @@ -130,7 +130,9 @@ export class McpConnectionManager { const key = connectionKey(config) if (this.connections.has(key) || this.connectingServers.has(key)) { - logger.info(`[${config.name}] Already has a managed connection or is connecting, skipping`) + logger.info('Already has a managed connection or is connecting, skipping', { + serverName: config.name, + }) const state = this.states.get(key) return { supportsListChanged: state?.supportsListChanged ?? false } } @@ -157,7 +159,8 @@ export class McpConnectionManager { }) if (!row.tokens) { logger.info( - `[${config.name}] OAuth server has no workspace tokens — skipping persistent connection until authorized` + 'OAuth server has no workspace tokens — skipping persistent connection until authorized', + { serverName: config.name } ) return { supportsListChanged: false } } @@ -200,16 +203,18 @@ export class McpConnectionManager { try { await withConnectTimeout(client, config.name) } catch (error) { - logger.error(`[${config.name}] Failed to connect for persistent monitoring:`, error) + logger.error('Failed to connect for persistent monitoring:', error, { + serverName: config.name, + }) return { supportsListChanged: false } } const supportsListChanged = client.hasListChangedCapability() if (!supportsListChanged) { - logger.info( - `[${config.name}] Server does not support listChanged — disconnecting (fallback to cache)` - ) + logger.info('Server does not support listChanged — disconnecting (fallback to cache)', { + serverName: config.name, + }) await client.disconnect() return { supportsListChanged: false } } @@ -234,7 +239,9 @@ export class McpConnectionManager { this.ensureIdleCheck() - logger.info(`[${config.name}] Persistent connection established (listChanged supported)`) + logger.info('Persistent connection established (listChanged supported)', { + serverName: config.name, + }) return { supportsListChanged: true } }) } finally { @@ -359,7 +366,7 @@ export class McpConnectionManager { timestamp: Date.now(), } - logger.info(`[${state.serverName}] Tools changed — publishing to pub/sub`) + logger.info('Tools changed — publishing to pub/sub', { serverName: state.serverName }) mcpPubSub?.publishToolsChanged(event) } @@ -373,7 +380,7 @@ export class McpConnectionManager { state.connected = false this.connections.delete(key) - logger.warn(`[${config.name}] Persistent connection lost, scheduling reconnect`) + logger.warn('Persistent connection lost, scheduling reconnect', { serverName: config.name }) this.scheduleReconnect(config, userId, workspaceId) } @@ -385,9 +392,9 @@ export class McpConnectionManager { if (!state || this.disposed) return if (state.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - logger.error( - `[${config.name}] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached — giving up` - ) + logger.error(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached — giving up`, { + serverName: config.name, + }) this.states.delete(key) return } @@ -399,7 +406,8 @@ export class McpConnectionManager { }) logger.info( - `[${config.name}] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})` + `Reconnecting in ${delay}ms (attempt ${state.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`, + { serverName: config.name } ) this.clearReconnectTimer(key) @@ -411,9 +419,9 @@ export class McpConnectionManager { const currentState = this.states.get(key) if (currentState?.connected) { - logger.info( - `[${config.name}] Connection already re-established externally, skipping reconnect` - ) + logger.info('Connection already re-established externally, skipping reconnect', { + serverName: config.name, + }) return } @@ -424,13 +432,13 @@ export class McpConnectionManager { try { const result = await this.connect(config, userId, workspaceId) if (result.supportsListChanged) { - logger.info(`[${config.name}] Reconnected successfully`) + logger.info('Reconnected successfully', { serverName: config.name }) } else { this.restoreReconnectState(config, userId, workspaceId, attempts) this.scheduleReconnect(config, userId, workspaceId) } } catch (error) { - logger.error(`[${config.name}] Reconnect failed:`, error) + logger.error('Reconnect failed:', error, { serverName: config.name }) this.restoreReconnectState(config, userId, workspaceId, attempts) this.scheduleReconnect(config, userId, workspaceId) } @@ -478,9 +486,9 @@ export class McpConnectionManager { const now = Date.now() for (const [key, state] of this.states) { if (now - state.lastActivity > IDLE_TIMEOUT_MS) { - logger.info( - `[${state.serverName}] Idle timeout reached, disconnecting managed connection` - ) + logger.info('Idle timeout reached, disconnecting managed connection', { + serverName: state.serverName, + }) this.disconnectByKey(key) } } diff --git a/apps/sim/lib/mcp/middleware.ts b/apps/sim/lib/mcp/middleware.ts index a3bec4ce0dd..039134808eb 100644 --- a/apps/sim/lib/mcp/middleware.ts +++ b/apps/sim/lib/mcp/middleware.ts @@ -183,7 +183,7 @@ async function validateMcpAuth( try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error}`) + logger.warn(`Authentication failed: ${auth.error}`, { requestId }) return { success: false, errorResponse: createMcpErrorResponse( @@ -270,7 +270,7 @@ async function validateMcpAuth( }, } } catch (error) { - logger.error(`[${requestId}] Error during MCP auth validation:`, error) + logger.error('Error during MCP auth validation:', error, { requestId }) return { success: false, errorResponse: createMcpErrorResponse( @@ -336,10 +336,9 @@ export function withMcpAuth>( } catch (error) { const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) if (bodyErrorResponse) return bodyErrorResponse - logger.error( - `[${(authResult as AuthResult).context.requestId}] Error in MCP route handler:`, - error - ) + logger.error('Error in MCP route handler:', error, { + requestId: (authResult as AuthResult).context.requestId, + }) return createMcpErrorResponse(toError(error), 'Internal server error', 500) } } diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index ee2cb01ee68..093073cbe7d 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -545,6 +545,23 @@ class McpService { ) } + /** Calls one tool with a grant that has no stored connection yet, such as during OAuth completion. */ + async callManagedMcpToolWithGrant( + serverId: string, + scope: string | ResourceScope, + auth: OAuthClientProvider, + toolCall: McpToolCall, + signal?: AbortSignal + ): Promise { + const config = await this.getServerConfig(serverId, scope) + if (!config) throw new Error('Managed MCP server is unavailable') + return this.withServerClient( + { key: '', serverId, allowPool: false }, + () => this.createManagedOauthClient(config, auth, signal), + (client) => client.callTool(toolCall, { signal }) + ) + } + async executeManagedMcpTool(params: { connectionId: string serverId: string @@ -746,7 +763,8 @@ class McpService { options.signal?.throwIfAborted() try { logger.info( - `[${requestId}] Executing MCP tool ${toolCall.name} on server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}` + `Executing MCP tool ${toolCall.name} on server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`, + { requestId } ) const config = await this.getServerConfig(serverId, workspaceId) @@ -779,7 +797,7 @@ class McpService { return client.callTool(toolCall, options) } ) - logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`) + logger.info(`Successfully executed tool ${toolCall.name}`, { requestId }) return result } catch (error) { options.signal?.throwIfAborted() @@ -788,8 +806,9 @@ class McpService { // the request. Timeouts/resets are NOT retried — the tool may have executed. if ((this.isSessionError(error) || isAuthError(error)) && attempt < maxRetries - 1) { logger.warn( - `[${requestId}] Retryable connection error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`, - error + `Retryable connection error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`, + error, + { requestId } ) await interruptibleSleep(100, options.signal) options.signal?.throwIfAborted() @@ -985,12 +1004,12 @@ class McpService { const discoveryStartedAt = new Date() try { - logger.info(`[${requestId}] Discovering MCP tools for workspace ${workspaceId}`) + logger.info(`Discovering MCP tools for workspace ${workspaceId}`, { requestId }) const servers = await this.getWorkspaceServers(workspaceId) if (servers.length === 0) { - logger.info(`[${requestId}] No servers found for workspace ${workspaceId}`) + logger.info(`No servers found for workspace ${workspaceId}`, { requestId }) return [] } @@ -1004,24 +1023,25 @@ class McpService { if (cached) return { kind: 'cached', tools: cached.tools } } catch (error) { logger.warn( - `[${requestId}] Cache read failed for ${config.name}, proceeding with discovery:`, - error + `Cache read failed for ${config.name}, proceeding with discovery:`, + error, + { requestId } ) } } if (refresh !== 'force' && (await this.isServerUnhealthy(workspaceId, config.id))) { - logger.info( - `[${requestId}] Skipping recently-failed server ${config.name} (negative-cache hit)` - ) + logger.info(`Skipping recently-failed server ${config.name} (negative-cache hit)`, { + requestId, + }) return { kind: 'unhealthy' } } try { const tools = await this.fetchServerTools(config, userId, workspaceId) - logger.debug( - `[${requestId}] Discovered ${tools.length} tools from server ${config.name}` - ) + logger.debug(`Discovered ${tools.length} tools from server ${config.name}`, { + requestId, + }) return { kind: 'fetched', tools } } catch (error) { if (isOauthAuthorizationError(error, config.authType)) { @@ -1065,7 +1085,7 @@ class McpService { this.cacheAdapter .set(serverCacheKey(workspaceId, server.id), outcome.tools, this.cacheTimeout) .catch((err) => - logger.warn(`[${requestId}] Cache write failed for ${server.name}:`, err) + logger.warn(`Cache write failed for ${server.name}:`, err, { requestId }) ) ) deferredSideEffects.push(this.clearServerFailure(workspaceId, server.id)) @@ -1076,7 +1096,7 @@ class McpService { // Mark disconnected so the UI surfaces the re-auth button, and drop the positive // tool cache so a follow-up force-refresh can't serve tools for a server that now // needs re-auth (mirrors the single-server discovery path). - logger.info(`[${requestId}] Skipping server ${server.name}: OAuth authorization pending`) + logger.info(`Skipping server ${server.name}: OAuth authorization pending`, { requestId }) deferredSideEffects.push( this.markServerOauthPending(server.id, workspaceId, discoveryStartedAt).then( async (statusApplied) => { @@ -1084,7 +1104,7 @@ class McpService { await this.cacheAdapter .delete(serverCacheKey(workspaceId, server.id)) .catch((err) => - logger.warn(`[${requestId}] Cache delete failed for ${server.name}:`, err) + logger.warn(`Cache delete failed for ${server.name}:`, err, { requestId }) ) } ) @@ -1097,9 +1117,9 @@ class McpService { return } failedCount++ - logger.warn( - `[${requestId}] Failed to discover tools from server ${server.name}: ${outcome.message}` - ) + logger.warn(`Failed to discover tools from server ${server.name}: ${outcome.message}`, { + requestId, + }) deferredSideEffects.push( this.updateServerStatus(server.id, workspaceId, { outcome: 'failed', @@ -1117,7 +1137,7 @@ class McpService { this.cacheAdapter .delete(serverCacheKey(workspaceId, server.id)) .catch((err) => - logger.warn(`[${requestId}] Cache delete failed for ${server.name}:`, err) + logger.warn(`Cache delete failed for ${server.name}:`, err, { requestId }) ), ]) }) @@ -1146,18 +1166,19 @@ class McpService { ) await manager.connect(resolvedConfig, userId, workspaceId) } catch (err) { - logger.warn(`[${requestId}] Persistent connection failed for ${config.name}:`, err) + logger.warn(`Persistent connection failed for ${config.name}:`, err, { requestId }) } })() } } logger.info( - `[${requestId}] Discovered ${allTools.length} tools from ${servers.length} servers (cached=${cachedCount} fetched=${fetchedCount} failed=${failedCount})` + `Discovered ${allTools.length} tools from ${servers.length} servers (cached=${cachedCount} fetched=${fetchedCount} failed=${failedCount})`, + { requestId } ) return allTools } catch (error) { - logger.error(`[${requestId}] Failed to discover MCP tools for user ${userId}:`, error) + logger.error(`Failed to discover MCP tools for user ${userId}:`, error, { requestId }) throw error } } @@ -1231,16 +1252,16 @@ class McpService { try { const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId)) if (cached) { - logger.debug(`[${requestId}] Cache hit for server ${serverId}`) + logger.debug(`Cache hit for server ${serverId}`, { requestId }) return cached.tools } } catch (error) { - logger.warn(`[${requestId}] Cache read failed for server ${serverId}:`, error) + logger.warn(`Cache read failed for server ${serverId}:`, error, { requestId }) } } if (refresh !== 'force' && (await this.isServerUnhealthy(workspaceId, serverId))) { - logger.info(`[${requestId}] Skipping recently-failed server ${serverId} (negative-cache)`) + logger.info(`Skipping recently-failed server ${serverId} (negative-cache)`, { requestId }) throw new McpServerCooldownError(serverId) } @@ -1249,7 +1270,8 @@ class McpService { let authType: McpServerConfig['authType'] try { logger.info( - `[${requestId}] Discovering tools from server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}` + `Discovering tools from server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`, + { requestId } ) const config = requesterConfig ?? (await this.getServerConfig(serverId, workspaceId)) @@ -1267,10 +1289,10 @@ class McpService { requireComplete, bestEffort ) - logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`) + logger.info(`Discovered ${tools.length} tools from server ${config.name}`, { requestId }) const cacheWrite = this.cacheAdapter .set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout) - .catch((err) => logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err)) + .catch((err) => logger.warn(`Cache write failed for ${config.name}:`, err, { requestId })) await Promise.allSettled( bestEffort ? [cacheWrite] @@ -1289,8 +1311,9 @@ class McpService { signal?.throwIfAborted() if (isRetryableDiscoveryError(error) && attempt < maxRetries - 1) { logger.warn( - `[${requestId}] Transient error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1}):`, - error + `Transient error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1}):`, + error, + { requestId } ) await interruptibleSleep( backoffWithJitter(attempt + 1, null, { baseMs: 250, maxMs: 2000 }), @@ -1313,7 +1336,7 @@ class McpService { this.cacheAdapter .delete(serverCacheKey(workspaceId, serverId)) .catch((err) => - logger.warn(`[${requestId}] Cache delete failed for ${serverId}:`, err) + logger.warn(`Cache delete failed for ${serverId}:`, err, { requestId }) ), this.markServerUnhealthy(workspaceId, serverId, error, authType), ]) @@ -1351,7 +1374,7 @@ class McpService { const requestId = generateRequestId() try { - logger.info(`[${requestId}] Getting server summaries for workspace ${workspaceId}`) + logger.info(`Getting server summaries for workspace ${workspaceId}`, { requestId }) const servers = await this.getWorkspaceServers(workspaceId) const summaries: McpServerSummary[] = [] @@ -1399,7 +1422,7 @@ class McpService { return summaries } catch (error) { - logger.error(`[${requestId}] Failed to get server summaries for user ${userId}:`, error) + logger.error(`Failed to get server summaries for user ${userId}:`, error, { requestId }) throw error } } diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index 827dd5c5a43..100ce90c6a2 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -276,7 +276,8 @@ async function restoreArchivedMcpToolsForWorkflow( MAX_MCP_SERVERS_PER_WORKFLOW - (await countWorkflowLiveMcpServers(tx, workflowId)) if (preLockServerBudget <= 0) { logger.warn( - `[${requestId}] Skipped restoring archived MCP tools for workflow ${workflowId}: it is already exposed on the maximum of ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers` + `Skipped restoring archived MCP tools for workflow ${workflowId}: it is already exposed on the maximum of ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers`, + { requestId } ) return } @@ -358,7 +359,8 @@ async function restoreArchivedMcpToolsForWorkflow( const skipReason = await getRestoreSkipReason(tx, workflowId, candidate, remainingServerBudget) if (skipReason) { logger.warn( - `[${requestId}] Skipped restoring archived MCP tool "${candidate.toolName}" for workflow ${workflowId} on server ${candidate.serverId}: ${skipReason}` + `Skipped restoring archived MCP tool "${candidate.toolName}" for workflow ${workflowId} on server ${candidate.serverId}: ${skipReason}`, + { requestId } ) continue } @@ -372,9 +374,9 @@ async function restoreArchivedMcpToolsForWorkflow( .set({ archivedAt: null }) .where(inArray(workflowMcpTool.id, restorableIds)) - logger.info( - `[${requestId}] Restored ${restorableIds.length} archived MCP tool(s) for workflow: ${workflowId}` - ) + logger.info(`Restored ${restorableIds.length} archived MCP tool(s) for workflow: ${workflowId}`, { + requestId, + }) } /** @@ -458,8 +460,9 @@ export async function syncMcpToolsForWorkflow( state = await loadDeployedWorkflowState(options.workflowId) } catch (error) { logger.error( - `[${options.requestId}] Error loading deployed state for MCP tool sync (${options.context ?? 'sync'}):`, - error + `Error loading deployed state for MCP tool sync (${options.context ?? 'sync'}):`, + error, + { requestId: options.requestId } ) if (options.throwOnError) throw error return [] @@ -577,13 +580,13 @@ export async function syncMcpToolsForWorkflow( afterToolId = pageTools.at(-1)?.id } - logger.info( - `[${requestId}] Synced ${syncedToolCount} MCP tool(s) for workflow (${context}): ${workflowId}` - ) + logger.info(`Synced ${syncedToolCount} MCP tool(s) for workflow (${context}): ${workflowId}`, { + requestId, + }) return [...affectedServerIds].map((serverId) => ({ serverId })) } catch (error) { - logger.error(`[${requestId}] Error syncing MCP tools (${context}):`, error) + logger.error(`Error syncing MCP tools (${context}):`, error, { requestId }) if (throwOnError) throw error return [] } @@ -631,11 +634,11 @@ export async function removeMcpToolsForWorkflow( .update(workflowMcpTool) .set({ archivedAt: new Date() }) .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) - logger.info(`[${requestId}] Archived MCP tools for workflow: ${workflowId}`) + logger.info(`Archived MCP tools for workflow: ${workflowId}`, { requestId }) return tools } catch (error) { - logger.error(`[${requestId}] Error archiving MCP tools:`, error) + logger.error('Error archiving MCP tools:', error, { requestId }) if (throwOnError) throw error return [] } diff --git a/apps/sim/lib/media/falai-result.ts b/apps/sim/lib/media/falai-result.ts new file mode 100644 index 00000000000..9fc45a9a72f --- /dev/null +++ b/apps/sim/lib/media/falai-result.ts @@ -0,0 +1,146 @@ +import { isRecordLike } from '@sim/utils/object' +import { withinDeadline } from '@/lib/core/utils/deadline' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, +} from '@/lib/core/utils/stream-limits' +import { + FALAI_COST_LOOKUP_TIMEOUT_MS, + type FalAICostMetadata, + getFalAIBillingReceipt, + getFalAIEstimatedCostMetadata, +} from '@/lib/tools/falai-pricing' + +const FALAI_RESULT_TIMEOUT_MS = 30_000 +const SAFE_ERROR_TYPES = new Set([ + 'content_policy_violation', + 'no_media_generated', + 'internal_server_error', + 'generation_timeout', + 'downstream_service_error', + 'downstream_service_unavailable', + 'value_error', + 'missing', + 'string_type', + 'int_parsing', + 'enum', + 'image_too_large', + 'image_too_small', +]) + +interface FalAIResultResponse { + ok: boolean + status: number + body: ReadableStream | null + headers: { get(name: string): string | null } +} + +/** A fal result error, without provider messages or inputs that may echo private content. */ +export class FalAIResultError extends Error { + readonly status: number + + constructor(providerStatus: number | undefined, errorTypes: string[]) { + super( + `Fal.ai result unavailable${providerStatus ? ` (HTTP ${providerStatus})` : ''}${errorTypes.length ? `: ${errorTypes.join(', ')}` : ''}` + ) + this.name = 'FalAIResultError' + this.status = providerStatus === 422 ? 422 : 502 + } +} + +function safeErrorTypes(value: unknown): string[] { + const details = isRecordLike(value) && Array.isArray(value.detail) ? value.detail : [value] + return [ + ...new Set( + details.flatMap((detail) => + isRecordLike(detail) && typeof detail.type === 'string' && SAFE_ERROR_TYPES.has(detail.type) + ? [detail.type] + : [] + ) + ), + ] +} + +/** + * Starts exact receipt tracking at queue completion while bounding result retrieval. Only explicit + * model failures skip estimates; transport failures still need a reconciliation record. + */ +export async function fetchFalAIResult({ + fetchResponse, + apiKey, + endpointId, + requestId, + trackCost, + onCost, + signal, + maxResponseBytes, + queueError, +}: { + fetchResponse: (signal: AbortSignal) => Promise + apiKey: string + endpointId: string + requestId: string + trackCost?: boolean + onCost?: (cost: FalAICostMetadata) => void + signal?: AbortSignal + maxResponseBytes: number + queueError?: unknown +}): Promise<{ data: unknown; cost?: FalAICostMetadata }> { + const lookup = { + apiKey, + endpointId, + requestId, + deadlineAt: Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, + } + let cost: FalAICostMetadata | undefined + let data: unknown + let generationFailed = Boolean(queueError) + const receipt = trackCost + ? getFalAIBillingReceipt(lookup).then((value) => { + if (value) { + cost = value + onCost?.(value) + } + return value + }) + : Promise.resolve(undefined) + try { + const [resultData] = await Promise.all([ + withinDeadline( + async (resultSignal) => { + if (generationFailed) throw new FalAIResultError(undefined, safeErrorTypes(queueError)) + const response = await fetchResponse(resultSignal) + if (!response.ok) { + generationFailed = response.status === 422 + const errorData = await readResponseJsonWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai result error response', + signal: resultSignal, + }).catch(() => undefined) + const types = safeErrorTypes(errorData) + generationFailed ||= response.status >= 500 && types.length > 0 + throw new FalAIResultError(response.status, types) + } + return readResponseJsonWithLimit(response, { + maxBytes: maxResponseBytes, + label: 'Fal.ai result response', + signal: resultSignal, + }) + }, + Date.now() + FALAI_RESULT_TIMEOUT_MS, + signal + ), + receipt, + ]) + data = resultData + } finally { + // Stop cannot erase a receipt; both pricing phases share the original ten-second deadline. + await receipt + if (trackCost && !cost && !generationFailed) { + cost = await getFalAIEstimatedCostMetadata(lookup) + onCost?.(cost) + } + } + signal?.throwIfAborted() + return { data, cost } +} diff --git a/apps/sim/lib/mothership/chat/completion.ts b/apps/sim/lib/mothership/chat/completion.ts index a7a0c051bfd..2f1c943d7d2 100644 --- a/apps/sim/lib/mothership/chat/completion.ts +++ b/apps/sim/lib/mothership/chat/completion.ts @@ -119,7 +119,8 @@ export function buildOnComplete(params: { } } catch (error) { if (error instanceof StreamControllerSupersededError) throw error - logger.error(`[${requestId}] Failed to persist chat messages`, { + logger.error('Failed to persist chat messages', { + requestId, chatId, error: getErrorMessage(error, 'Unknown error'), }) @@ -183,7 +184,8 @@ export function buildOnError(params: { } } catch (error) { if (error instanceof StreamControllerSupersededError) throw error - logger.error(`[${requestId}] Failed to finalize errored chat stream`, { + logger.error('Failed to finalize errored chat stream', { + requestId, chatId, error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index bb68ef96984..dc5fbb0e553 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -425,7 +425,7 @@ async function resolveAgentContexts(params: { requestMode ) } catch (error) { - logger.error(`[${requestId}] Failed to process contexts`, error) + logger.error('Failed to process contexts', error, { requestId }) } } @@ -1410,7 +1410,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { if (isZodError(error)) { // A rejected body otherwise leaves no trace: the client sees a 400 and // its stream reconnect 404s, which reads as the stream dying for no reason. - logger.warn(`[${requestId}] Rejected chat request as invalid`, { + logger.warn('Rejected chat request as invalid', { + requestId, issues: error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message, @@ -1442,7 +1443,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 }) } - logger.error(`[${requestId}] Error handling unified chat request`, { + logger.error('Error handling unified chat request', { + requestId, error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) diff --git a/apps/sim/lib/mothership/chat/project-file-context.integration.ts b/apps/sim/lib/mothership/chat/project-file-context.integration.ts index bd2b847f1d9..3984333c8e6 100644 --- a/apps/sim/lib/mothership/chat/project-file-context.integration.ts +++ b/apps/sim/lib/mothership/chat/project-file-context.integration.ts @@ -694,7 +694,7 @@ describe('open tabs that no longer resolve', () => { }) expect(contexts[1]?.content).toContain(browserTab.url) expect(logs.calls('info', 'Skipped resource attachment that no longer exists')).toEqual([ - { resourceType: 'file' }, + { requestId: expect.any(String), resourceType: 'file' }, ]) expect(logs.calls('error', 'Failed to resolve resource attachment')).toHaveLength(1) expect( @@ -737,7 +737,7 @@ describe('open tabs that no longer resolve', () => { expect(contexts[0]?.content).toContain(browserTab.url) expect( logs.calls('warn', 'Skipped resource attachment the user can no longer access') - ).toEqual([{ resourceType: 'file' }]) + ).toEqual([{ requestId: expect.any(String), resourceType: 'file' }]) expect(logs.calls('info', 'Skipped resource attachment that no longer exists')).toEqual([]) expect(logs.calls('error', 'Failed to resolve resource attachment')).toEqual([]) } finally { diff --git a/apps/sim/lib/mothership/chat/resource-attachments.ts b/apps/sim/lib/mothership/chat/resource-attachments.ts index 8367a82ce9f..beb7218a496 100644 --- a/apps/sim/lib/mothership/chat/resource-attachments.ts +++ b/apps/sim/lib/mothership/chat/resource-attachments.ts @@ -209,15 +209,17 @@ export async function resolveResourceAttachmentContexts(params: { const resourceType = resourceAttachments[index]?.type const code = asOrchestrationError(result.reason)?.code if (code === 'not_found') { - logger.info(`[${requestId}] Skipped resource attachment that no longer exists`, { + logger.info('Skipped resource attachment that no longer exists', { + requestId, resourceType, }) } else if (code === 'forbidden') { - logger.warn(`[${requestId}] Skipped resource attachment the user can no longer access`, { + logger.warn('Skipped resource attachment the user can no longer access', { + requestId, resourceType, }) } else { - logger.error(`[${requestId}] Failed to resolve resource attachment`, result.reason) + logger.error('Failed to resolve resource attachment', result.reason, { requestId }) } }) } diff --git a/apps/sim/lib/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index ca812c94634..ca2f084e5b1 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -156,7 +156,9 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/fireflies.mdx', 'integrations/fivetran.mdx', 'integrations/flint.mdx', + 'integrations/flow.mdx', 'integrations/gamma.mdx', + 'integrations/gauge.mdx', 'integrations/github.mdx', 'integrations/gitlab.mdx', 'integrations/gmail.mdx', @@ -203,6 +205,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/instantly.mdx', 'integrations/intercom.mdx', 'integrations/intune.mdx', + 'integrations/jama.mdx', 'integrations/jina.mdx', 'integrations/jira.mdx', 'integrations/jira_service_management.mdx', @@ -444,6 +447,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'search/connect-your-account.mdx', 'search/find-content.mdx', 'search/fireflies.mdx', + 'search/flow.mdx', 'search/generic-secrets.mdx', 'search/github.mdx', 'search/gitlab.mdx', @@ -453,6 +457,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'search/google-meet.mdx', 'search/granola.mdx', 'search/hubspot.mdx', + 'search/jama.mdx', 'search/jira.mdx', 'search/linear.mdx', 'search/lucid.mdx', diff --git a/apps/sim/lib/mothership/generated/mothership-stream-v1.ts b/apps/sim/lib/mothership/generated/mothership-stream-v1.ts index 69407a78cf1..2889f7cc1c1 100644 --- a/apps/sim/lib/mothership/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/mothership/generated/mothership-stream-v1.ts @@ -23,6 +23,7 @@ export type MothershipStreamV1EventEnvelope = | MothershipStreamV1RunResumedEventEnvelope | MothershipStreamV1CompactionStartEventEnvelope | MothershipStreamV1CompactionDoneEventEnvelope + | MothershipStreamV1SteeringAppliedEventEnvelope | MothershipStreamV1TaskArmedEventEnvelope | MothershipStreamV1TaskDeliveredEventEnvelope | MothershipStreamV1ErrorEventEnvelope @@ -371,6 +372,25 @@ export interface MothershipStreamV1CompactionDonePayload { export interface MothershipStreamV1CompactionDoneData { summary_chars: number } +export interface MothershipStreamV1SteeringAppliedEventEnvelope { + payload: MothershipStreamV1SteeringAppliedPayload + scope?: MothershipStreamV1StreamScope + seq: number + stream: MothershipStreamV1StreamRef + trace?: MothershipStreamV1Trace + ts: string + type: 'run' + v: 1 +} +export interface MothershipStreamV1SteeringAppliedPayload { + content?: string + eventSeq?: number + kind: 'steering_applied' + messageId?: string + mode?: 'deferred' | 'interrupt' + steeringId: string + textOffset?: number +} export interface MothershipStreamV1TaskArmedEventEnvelope { payload: MothershipStreamV1TaskArmedPayload scope?: MothershipStreamV1StreamScope diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 64b473165f1..f42d7216caa 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -511,6 +511,94 @@ export interface SteerRequest { content: string; } +/** + * The images one run takes in steers over its whole life. With a turn's own five they stay + * under the twenty image and document blocks a model request holds. + */ +export const MAX_RUN_STEERED_IMAGES = 10; + +/** The most attachments and mentions one steer carries: Sim's MAX_CHAT_CONTEXTS for a turn. */ +export const MAX_STEER_CONTEXTS = 100; + +/** + * POST /api/streams/steer/v2: steer the live run `messageId` with one immutable input, answered + * with its durable disposition, which a retry of the same body reads back. Only `not_applied` + * (or a 422) lets the caller send the input as a new turn, with the steeringId as its + * messageId; a turn for an input a run applied is refused. Refusals keep nothing: + * - 400: an invalid body, or images outside an organization chat (`steering_scope`). + * - 404 `steering_target_missing`: no such run (or not admitted yet). + * - 409 `steering_conflict`: a different input under this steeringId. + * - 422 `steering_image_limit`: past MAX_RUN_STEERED_IMAGES, applied images included. Final + * for the run: send the input as the next turn once the run ends. + * - 429 `steering_capacity`: the pending queue is full; retryable once the run applies it. + */ +export const SteerV2Request = z.strictObject({ + messageId: z.uuid(), + steeringId: z.uuid(), + content: z.string().min(1).max(1_048_576), + /** Attachments and @-mentions, rendered into the steer the way a turn renders its own. */ + context: ChatPayloadSchema.shape.context.unwrap().max(MAX_STEER_CONTEXTS).default([]), + /** Only an organization chat accepts images, as for a turn (400 otherwise). */ + assistantImages: ChatPayloadSchema.shape.assistantImages, +}); +export type SteerV2Request = z.infer; + +/** + * - `queued`: accepted; the run applies it at its next model step, or ends first and then + * reports `not_applied`. + * - `applied`: logged into the run's model input, which its next model request carries (as + * v1's frame means). `eventSeq` is its steering_input event in the chat + * log and `textOffset` the main answer's length when it applied (UTF-16 code units), the + * same values the `steering_applied` frame carries. + * - `not_applied`: the run ended, or was stopped, before applying it, and never will. A run + * that would apply it only by exceeding its usage limit, or one whose input the request + * supplies (an execution, a structured response), ends instead and reports this. + * + * A steeringId belongs to the run of the chat that first accepted it: a retry naming another + * run of the chat reads that run's disposition. + */ +export const SteerV2Response = z.discriminatedUnion("status", [ + z.strictObject({ steeringId: z.string(), status: z.literal("queued") }), + z.strictObject({ + steeringId: z.string(), + status: z.literal("applied"), + eventSeq: z.number().int().positive(), + textOffset: z.number().int().nonnegative(), + }), + z.strictObject({ steeringId: z.string(), status: z.literal("not_applied") }), +]); +export type SteerV2Response = z.infer; + +/** + * POST /api/streams/steer/v2/disposition: what became of the input `steeringId`, read without + * sending it. It never queues, renders or claims anything. An input the chat holds answers as + * a retry of it on /api/streams/steer/v2 would (the chat-wide identity included). One it does + * not hold answers `not_applied` once the run `messageId` has ended or stopped, since no + * steer could reach it any more, and `unknown` while the run is live: nothing was delivered, + * and sending it is still the caller's choice. A run that does not exist (or another + * tenant's) is a 404 `steering_target_missing`, as for a steer. + */ +export const SteerDispositionRequest = z.strictObject({ + messageId: z.uuid(), + steeringId: z.uuid(), +}); +export type SteerDispositionRequest = z.infer; + +export const SteerDispositionResponse = z.discriminatedUnion("status", [ + ...SteerV2Response.options, + z.strictObject({ steeringId: z.string(), status: z.literal("unknown") }), +]); +export type SteerDispositionResponse = z.infer; + +/** The `run`/`steering_applied` frame: a steer reached the run's model input. */ +export interface StreamSteeringApplied { + kind: "steering_applied"; + steeringId: string; + /** As in SteerV2Response's `applied`; absent only on frames from workers without steer v2. */ + eventSeq?: number | undefined; + textOffset?: number | undefined; +} + /** POST /api/chats/fork — copy a selected conversation snapshot, never live execution. */ export const ForkChatRequest = z .strictObject({ diff --git a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts index 533d43ca419..3f5c1392d3b 100644 --- a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts +++ b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts @@ -13,6 +13,8 @@ export const liveSearchProviderSchema = z.enum([ 'jira', 'confluence', 'github', + 'jama', + 'flow', 'gitlab', 'linear', 'lucid', @@ -478,6 +480,8 @@ export const workspaceKnowledgeSearchResultSchema = z.object({ sourceDate: z.string().nullable().optional(), sourceContainerName: z.string().optional(), sourceContainerUrl: z.string().optional(), + /** Zero-based nativeQueries entries that returned this result, retained through rank fusion. */ + queryIndexes: z.array(z.number().int().min(0).max(8)).min(1).max(9).optional(), sourceDateType: z.enum(['event_start', 'message', 'modified']).optional(), /** The person behind the document, from its author-like tag; null when the source names none. */ author: z.string().nullable(), diff --git a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts index fce64c58f62..28d1f4f65f0 100644 --- a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts @@ -7,9 +7,11 @@ export interface ToolCatalogEntry { clientExecutable?: boolean description?: | 'Create a workspace in the conversation’s organization under the current user’s workspace-creation policy. Returns its ID for subsequent explicitly workspace-scoped commands. Includes a starter workflow unless skipDefaultWorkflow is true.' + | 'Create and run the selected workspace’s workflow tests. create takes a name, a one-line title for the concern, and an optional description, and returns tests/.test.js; write the cases into that file with the file tools. A test file is plain vitest: import { describe, it, expect, vi } from "vitest" and { runWorkflow, mockBlock, mockTool, spyOnBlock } from "sim:test", one top-level describe, an it per case. runWorkflow(name, input) runs a workflow and returns { output } (pass { trigger: "Trigger block name" } as a third argument when it has several triggers); mockBlock(blockName) returns a vi.fn whose value replaces that block’s output and records its inputs; mockTool(toolId) or mockTool(agentBlockName, toolId) answers an Agent’s calls to that tool the same way while the model still runs, naming built-in tools by id (slack_message), MCP tools by server as mockTool({ mcp: "Server name", tool: "tool_name" }), and custom tools by title as mockTool({ customTool: "Title" }); .mockSampleOutput({ ...overrides }) on either mock makes it answer with a placeholder output shaped like the real one, with your fields merged in; await expect(value).toMatchRubric(rubric) asks a model judge for pass or fail. Every write is checked and refused if the file does not load. run takes a version (draft while editing, deployed before shipping), waits, and returns each file’s failures with line numbers and messages. list and get report status; update changes title or description.' | 'Discover and configure organization Search sources. list/get return accessible sources and indexing status; providers returns available integration approvals; approve changes a provider approval when authorized; setup returns the existing connection UI for the user to complete. Put the returned setupUrl in a clickable Markdown link at the end of the reply. Setup does not mean connected or indexed. Use search_workspace and read_document to retrieve source content.' | 'List accessible Projects within this conversation, with visible environments and current capabilities. Follow nextCursor for more results; discovery does not grant file write access.' | 'List currently accessible workspaces with roles and explicit capability restrictions. Bulk results report copilotAllowed and deniedCapabilities; exact workspaceId returns the full capability map. Omitted restrictions never authorize an operation.' + | 'Publish and edit the selected workspace’s changelog. publish takes a title, a markdown body, the changes (one line each, with the workflowId, deploymentVersionId, and chatId they came from when known), and bump: major, minor, or patch for how significant the release is, with a one-line bumpReason. The server computes the version from the workspace’s highest one; never write a version number on publish. It returns the release with its revision and changelog/.md, the body file: edit the body there with the file tools, for example to add impact charts once there is data. update changes the title, bumpReason, version label, or the whole change list, and requires expectedRevision from get, list, or publish so a concurrent edit is never overwritten. A deployment ships in one release: a deploymentVersionId already in another release is rejected. list pages newest first; get returns one release with its revision and its changelog/.md path.' | 'Read and manage account, organization, and workspace settings. list finds sections; get returns current values, updateSchema and operation names; describe returns one operation’s exact input schema; update changes narrow preferences; execute performs a listed operation; open returns the existing user setup flow. When user setup is needed, put the returned setupUrl in a clickable Markdown link at the end of the reply. Workspace resources retain their CLI commands. Account is the acting user; organization is the conversation’s organization. Every operation checks current permissions and entitlements.' | 'Read and save the selected workspace’s single dashboard, validated YAML over live tables. Load the create-dashboard skill for the schema. get returns content and revision, or nulls when the workspace has no dashboard yet; set with no revision creates it. Replacing an existing dashboard requires expectedRevision from get, so a concurrent edit is never overwritten. Use open_resource with type dashboard to show the result.' hidden?: boolean @@ -51,6 +53,7 @@ export interface ToolCatalogEntry { | 'browser_zoom' | 'call_integration_tool' | 'cancel_workflow_run' + | 'changelog' | 'connect_slack_bot' | 'cp' | 'create_empty_file' @@ -147,6 +150,7 @@ export interface ToolCatalogEntry { | 'table_views' | 'tail_agent' | 'terminal' + | 'tests' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' @@ -1041,23 +1045,6 @@ export const BrowserFillForm: ToolCatalogEntry = { description: "Ordered list of 1–8 fields with unique elementId refs from the current top page's latest snapshot. Supply exactly one matching value parameter per kind.", items: { - oneOf: [ - { - additionalProperties: false, - properties: { elementId: {}, kind: { enum: ['text'] }, text: {} }, - required: ['text'], - }, - { - additionalProperties: false, - properties: { elementId: {}, kind: { enum: ['select'] }, value: {} }, - required: ['value'], - }, - { - additionalProperties: false, - properties: { checked: {}, elementId: {}, kind: { enum: ['checked'] } }, - required: ['checked'], - }, - ], properties: { checked: { description: @@ -1959,7 +1946,6 @@ export const BrowserSelectOption: ToolCatalogEntry = { route: 'client', mode: 'async', parameters: { - oneOf: [{ required: ['value'] }, { required: ['values'] }], properties: { elementId: { description: @@ -6120,6 +6106,8 @@ export const SearchWorkspace: ToolCatalogEntry = { 'jira', 'confluence', 'github', + 'jama', + 'flow', 'gitlab', 'linear', 'lucid', @@ -7712,6 +7700,21 @@ export const Workflow: ToolCatalogEntry = { internal: true, } +export const ListUserProjects: ToolCatalogEntry = { + id: 'list_user_projects', + description: + 'List accessible Projects within this conversation, with visible environments and current capabilities. Follow nextCursor for more results; discovery does not grant file write access.', + route: 'sim', + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + cursor: { type: 'string', minLength: 1 }, + limit: { default: 50, type: 'integer', minimum: 1, maximum: 100 }, + }, + }, +} + export const ListWorkspaces: ToolCatalogEntry = { id: 'list_workspaces', description: @@ -7780,6 +7783,264 @@ export const Dashboards: ToolCatalogEntry = { }, } +export const Tests: ToolCatalogEntry = { + id: 'tests', + description: + 'Create and run the selected workspace’s workflow tests. create takes a name, a one-line title for the concern, and an optional description, and returns tests/.test.js; write the cases into that file with the file tools. A test file is plain vitest: import { describe, it, expect, vi } from "vitest" and { runWorkflow, mockBlock, mockTool, spyOnBlock } from "sim:test", one top-level describe, an it per case. runWorkflow(name, input) runs a workflow and returns { output } (pass { trigger: "Trigger block name" } as a third argument when it has several triggers); mockBlock(blockName) returns a vi.fn whose value replaces that block’s output and records its inputs; mockTool(toolId) or mockTool(agentBlockName, toolId) answers an Agent’s calls to that tool the same way while the model still runs, naming built-in tools by id (slack_message), MCP tools by server as mockTool({ mcp: "Server name", tool: "tool_name" }), and custom tools by title as mockTool({ customTool: "Title" }); .mockSampleOutput({ ...overrides }) on either mock makes it answer with a placeholder output shaped like the real one, with your fields merged in; await expect(value).toMatchRubric(rubric) asks a model judge for pass or fail. Every write is checked and refused if the file does not load. run takes a version (draft while editing, deployed before shipping), waits, and returns each file’s failures with line numbers and messages. list and get report status; update changes title or description.', + route: 'sim', + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + workspaceId: { type: 'string', minLength: 1, maxLength: 100 }, + action: { + anyOf: [ + { type: 'string', const: 'create' }, + { type: 'string', const: 'update' }, + { type: 'string', const: 'list' }, + { type: 'string', const: 'get' }, + { type: 'string', const: 'run' }, + { type: 'string', const: 'delete' }, + ], + }, + name: { + description: + 'Test file name: tests/.test.js, lowercase letters, numbers, and dashes. Required for action: create, update, get, delete. Only used for action: create, update, get, delete. Omit for other actions.', + type: 'string', + pattern: '^(?!run$)[a-z0-9][a-z0-9-]{0,79}$', + }, + title: { + description: + 'Required for action: create. Only used for action: create, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'The concern this file tests, in one line.', + }, + { type: 'string', minLength: 1, maxLength: 200 }, + ], + }, + description: { + description: 'Only used for action: create, update. Omit for other actions.', + anyOf: [ + { type: 'string', maxLength: 2000 }, + { anyOf: [{ type: 'string', maxLength: 2000 }, { type: 'null' }] }, + ], + }, + version: { + description: + 'Which version of the workflows to test: draft while editing, deployed before shipping. Required for action: list, get, run. Only used for action: list, get, run. Omit for other actions.', + type: 'string', + enum: ['draft', 'deployed'], + }, + names: { + description: + 'Omit to run every test file. Only used for action: run. Omit for other actions.', + minItems: 1, + maxItems: 100, + type: 'array', + items: { + type: 'string', + pattern: '^(?!run$)[a-z0-9][a-z0-9-]{0,79}$', + description: + 'Test file name: tests/.test.js, lowercase letters, numbers, and dashes.', + }, + }, + only: { + description: + 'With exactly one name: the "describe > it" test names to run in it. Only used for action: run. Omit for other actions.', + minItems: 1, + maxItems: 100, + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 500 }, + }, + }, + required: ['action'], + additionalProperties: false, + }, +} + +export const Changelog: ToolCatalogEntry = { + id: 'changelog', + description: + 'Publish and edit the selected workspace’s changelog. publish takes a title, a markdown body, the changes (one line each, with the workflowId, deploymentVersionId, and chatId they came from when known), and bump: major, minor, or patch for how significant the release is, with a one-line bumpReason. The server computes the version from the workspace’s highest one; never write a version number on publish. It returns the release with its revision and changelog/.md, the body file: edit the body there with the file tools, for example to add impact charts once there is data. update changes the title, bumpReason, version label, or the whole change list, and requires expectedRevision from get, list, or publish so a concurrent edit is never overwritten. A deployment ships in one release: a deploymentVersionId already in another release is rejected. list pages newest first; get returns one release with its revision and its changelog/.md path.', + route: 'sim', + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + workspaceId: { type: 'string', minLength: 1, maxLength: 100 }, + action: { + anyOf: [ + { type: 'string', const: 'list' }, + { type: 'string', const: 'get' }, + { type: 'string', const: 'publish' }, + { type: 'string', const: 'update' }, + ], + }, + cursor: { + description: + 'nextCursor from the previous list. Only used for action: list. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 256, + }, + releaseId: { + description: + 'The release id from list or publish. Required for action: get, update. Only used for action: get, update. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + title: { + description: + 'Required for action: publish. Only used for action: publish, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'The release headline: what people get, not how.', + }, + { type: 'string', minLength: 1, maxLength: 200 }, + ], + }, + body: { + description: + 'Markdown body: a short summary, then evidence (```dashboard embeds for impact charts and stats). Required for action: publish. Only used for action: publish. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 262144, + }, + bump: { + description: + 'How significant the release is. The server computes the version from the highest one. Required for action: publish. Only used for action: publish. Omit for other actions.', + type: 'string', + enum: ['major', 'minor', 'patch'], + }, + bumpReason: { + description: + 'Required for action: publish. Only used for action: publish, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'Why this release is a major, minor, or patch release, in one line.', + }, + { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'Why this release is a major, minor, or patch release, in one line.', + }, + ], + }, + changes: { + description: + 'Required for action: publish. Only used for action: publish, update. Omit for other actions.', + anyOf: [ + { + minItems: 1, + maxItems: 50, + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'What changed, in one line a user reads.', + }, + workflowId: { + description: 'The workflow it changed.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + deploymentVersionId: { + description: + 'The deployment it shipped in; requires workflowId of that workflow.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + chatId: { + description: + 'The chat where the change was made; defaults to the chat publishing it.', + type: 'string', + format: 'uuid', + pattern: + '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', + }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + { + description: 'Replaces every change line of the release.', + maxItems: 50, + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'What changed, in one line a user reads.', + }, + workflowId: { + description: 'The workflow it changed.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + deploymentVersionId: { + description: + 'The deployment it shipped in; requires workflowId of that workflow.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + chatId: { + description: + 'The chat where the change was made; defaults to the chat publishing it.', + type: 'string', + format: 'uuid', + pattern: + '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', + }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + ], + }, + expectedRevision: { + description: + 'The revision from get, list, or publish. Required for action: update. Only used for action: update. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 32, + }, + version: { + description: 'Only used for action: update. Omit for other actions.', + type: 'string', + minLength: 5, + maxLength: 40, + }, + }, + required: ['action'], + additionalProperties: false, + }, +} + export const Workspaces: ToolCatalogEntry = { id: 'workspaces', description: @@ -7926,21 +8187,6 @@ export const SearchSources: ToolCatalogEntry = { }, } -export const ListUserProjects: ToolCatalogEntry = { - id: 'list_user_projects', - description: - 'List accessible Projects within this conversation, with visible environments and current capabilities. Follow nextCursor for more results; discovery does not grant file write access.', - route: 'sim', - parameters: { - $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', - properties: { - cursor: { type: 'string', minLength: 1 }, - limit: { default: 50, type: 'integer', minimum: 1, maximum: 100 }, - }, - }, -} - export const FfmpegOperation = { overlayAudio: 'overlay_audio', mixAudio: 'mix_audio', @@ -8509,10 +8755,12 @@ export const TOOL_CATALOG: Record = { [WebScrape.id]: WebScrape, [WebSearch.id]: WebSearch, [Workflow.id]: Workflow, + [ListUserProjects.id]: ListUserProjects, [ListWorkspaces.id]: ListWorkspaces, [Dashboards.id]: Dashboards, + [Tests.id]: Tests, + [Changelog.id]: Changelog, [Workspaces.id]: Workspaces, [Settings.id]: Settings, [SearchSources.id]: SearchSources, - [ListUserProjects.id]: ListUserProjects, } diff --git a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts index 7b184bd3fd3..f12b94422b8 100644 --- a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts @@ -836,41 +836,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: "Ordered list of 1–8 fields with unique elementId refs from the current top page's latest snapshot. Supply exactly one matching value parameter per kind.", items: { - oneOf: [ - { - additionalProperties: false, - properties: { - elementId: {}, - kind: { - enum: ['text'], - }, - text: {}, - }, - required: ['text'], - }, - { - additionalProperties: false, - properties: { - elementId: {}, - kind: { - enum: ['select'], - }, - value: {}, - }, - required: ['value'], - }, - { - additionalProperties: false, - properties: { - checked: {}, - elementId: {}, - kind: { - enum: ['checked'], - }, - }, - required: ['checked'], - }, - ], properties: { checked: { description: @@ -1888,14 +1853,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, browser_select_option: { parameters: { - oneOf: [ - { - required: ['value'], - }, - { - required: ['values'], - }, - ], properties: { elementId: { description: @@ -6062,6 +6019,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'jira', 'confluence', 'github', + 'jama', + 'flow', 'gitlab', 'linear', 'lucid', @@ -7822,6 +7781,25 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + list_user_projects: { + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + cursor: { + type: 'string', + minLength: 1, + }, + limit: { + default: 50, + type: 'integer', + minimum: 1, + maximum: 100, + }, + }, + }, + resultSchema: undefined, + }, list_workspaces: { parameters: { $schema: 'http://json-schema.org/draft-07/schema#', @@ -7898,6 +7876,319 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + tests: { + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + workspaceId: { + type: 'string', + minLength: 1, + maxLength: 100, + }, + action: { + anyOf: [ + { + type: 'string', + const: 'create', + }, + { + type: 'string', + const: 'update', + }, + { + type: 'string', + const: 'list', + }, + { + type: 'string', + const: 'get', + }, + { + type: 'string', + const: 'run', + }, + { + type: 'string', + const: 'delete', + }, + ], + }, + name: { + description: + 'Test file name: tests/.test.js, lowercase letters, numbers, and dashes. Required for action: create, update, get, delete. Only used for action: create, update, get, delete. Omit for other actions.', + type: 'string', + pattern: '^(?!run$)[a-z0-9][a-z0-9-]{0,79}$', + }, + title: { + description: + 'Required for action: create. Only used for action: create, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'The concern this file tests, in one line.', + }, + { + type: 'string', + minLength: 1, + maxLength: 200, + }, + ], + }, + description: { + description: 'Only used for action: create, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + maxLength: 2000, + }, + { + anyOf: [ + { + type: 'string', + maxLength: 2000, + }, + { + type: 'null', + }, + ], + }, + ], + }, + version: { + description: + 'Which version of the workflows to test: draft while editing, deployed before shipping. Required for action: list, get, run. Only used for action: list, get, run. Omit for other actions.', + type: 'string', + enum: ['draft', 'deployed'], + }, + names: { + description: + 'Omit to run every test file. Only used for action: run. Omit for other actions.', + minItems: 1, + maxItems: 100, + type: 'array', + items: { + type: 'string', + pattern: '^(?!run$)[a-z0-9][a-z0-9-]{0,79}$', + description: + 'Test file name: tests/.test.js, lowercase letters, numbers, and dashes.', + }, + }, + only: { + description: + 'With exactly one name: the "describe > it" test names to run in it. Only used for action: run. Omit for other actions.', + minItems: 1, + maxItems: 100, + type: 'array', + items: { + type: 'string', + minLength: 1, + maxLength: 500, + }, + }, + }, + required: ['action'], + additionalProperties: false, + }, + resultSchema: undefined, + }, + changelog: { + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + workspaceId: { + type: 'string', + minLength: 1, + maxLength: 100, + }, + action: { + anyOf: [ + { + type: 'string', + const: 'list', + }, + { + type: 'string', + const: 'get', + }, + { + type: 'string', + const: 'publish', + }, + { + type: 'string', + const: 'update', + }, + ], + }, + cursor: { + description: + 'nextCursor from the previous list. Only used for action: list. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 256, + }, + releaseId: { + description: + 'The release id from list or publish. Required for action: get, update. Only used for action: get, update. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + title: { + description: + 'Required for action: publish. Only used for action: publish, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'The release headline: what people get, not how.', + }, + { + type: 'string', + minLength: 1, + maxLength: 200, + }, + ], + }, + body: { + description: + 'Markdown body: a short summary, then evidence (```dashboard embeds for impact charts and stats). Required for action: publish. Only used for action: publish. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 262144, + }, + bump: { + description: + 'How significant the release is. The server computes the version from the highest one. Required for action: publish. Only used for action: publish. Omit for other actions.', + type: 'string', + enum: ['major', 'minor', 'patch'], + }, + bumpReason: { + description: + 'Required for action: publish. Only used for action: publish, update. Omit for other actions.', + anyOf: [ + { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'Why this release is a major, minor, or patch release, in one line.', + }, + { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'Why this release is a major, minor, or patch release, in one line.', + }, + ], + }, + changes: { + description: + 'Required for action: publish. Only used for action: publish, update. Omit for other actions.', + anyOf: [ + { + minItems: 1, + maxItems: 50, + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'What changed, in one line a user reads.', + }, + workflowId: { + description: 'The workflow it changed.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + deploymentVersionId: { + description: + 'The deployment it shipped in; requires workflowId of that workflow.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + chatId: { + description: + 'The chat where the change was made; defaults to the chat publishing it.', + type: 'string', + format: 'uuid', + pattern: + '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', + }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + { + description: 'Replaces every change line of the release.', + maxItems: 50, + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'What changed, in one line a user reads.', + }, + workflowId: { + description: 'The workflow it changed.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + deploymentVersionId: { + description: + 'The deployment it shipped in; requires workflowId of that workflow.', + type: 'string', + minLength: 1, + maxLength: 128, + }, + chatId: { + description: + 'The chat where the change was made; defaults to the chat publishing it.', + type: 'string', + format: 'uuid', + pattern: + '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', + }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + ], + }, + expectedRevision: { + description: + 'The revision from get, list, or publish. Required for action: update. Only used for action: update. Omit for other actions.', + type: 'string', + minLength: 1, + maxLength: 32, + }, + version: { + description: 'Only used for action: update. Omit for other actions.', + type: 'string', + minLength: 5, + maxLength: 40, + }, + }, + required: ['action'], + additionalProperties: false, + }, + resultSchema: undefined, + }, workspaces: { parameters: { $schema: 'http://json-schema.org/draft-07/schema#', @@ -8094,23 +8385,4 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_projects: { - parameters: { - $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', - properties: { - cursor: { - type: 'string', - minLength: 1, - }, - limit: { - default: 50, - type: 'integer', - minimum: 1, - maximum: 100, - }, - }, - }, - resultSchema: undefined, - }, } diff --git a/apps/sim/lib/mothership/request/lifecycle/finalize.ts b/apps/sim/lib/mothership/request/lifecycle/finalize.ts index 2bbab2c5167..67dff02c99a 100644 --- a/apps/sim/lib/mothership/request/lifecycle/finalize.ts +++ b/apps/sim/lib/mothership/request/lifecycle/finalize.ts @@ -82,7 +82,8 @@ async function handleAborted( const partialContentLen = result.content?.length ?? 0 const toolCallCount = result.toolCalls?.length ?? 0 const blockCount = result.contentBlocks?.length ?? 0 - logger.info(`[${requestId}] Stream aborted by explicit stop`, { + logger.info('Stream aborted by explicit stop', { + requestId, partialContentLen, toolCallCount, blockCount, @@ -132,9 +133,10 @@ async function handleError( const toolCallCount = result.toolCalls?.length ?? 0 if (publisher.clientDisconnected) { - logger.info(`[${requestId}] Stream failed after client disconnect`, { error: errorMessage }) + logger.info('Stream failed after client disconnect', { requestId, error: errorMessage }) } - logger.error(`[${requestId}] Orchestration returned failure`, { + logger.error('Orchestration returned failure', { + requestId, error: errorMessage, partialContentLen, toolCallCount, @@ -233,7 +235,8 @@ async function loggedRunStatusUpdate( try { await updateRunStatus(runId, status, updates, controllerToken) } catch (error) { - logger.warn(`[${requestId}] Failed to update run status to ${status}`, { + logger.warn(`Failed to update run status to ${status}`, { + requestId, runId, error: toError(error).message, }) diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index d499c7b136f..0ae55e90c2d 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -153,7 +153,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS () => false, (error: unknown) => { if (error instanceof StreamControllerSupersededError) return true - logger.warn(`[${requestId}] Chat stream lease is unreadable`, { + logger.warn('Chat stream lease is unreadable', { + requestId, streamId, error: getErrorMessage(error), }) @@ -210,7 +211,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS })() // Contract violation: add the new reason to AbortReason / // isExplicitStopReason or extend the classifier. - logger.error(`[${requestId}] Stream cancelled with unknown abort reason`, { + logger.error('Stream cancelled with unknown abort reason', { + requestId, streamId, chatId, reason: serializedReason, @@ -259,7 +261,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS failure: StreamTurnFailure, result?: OrchestratorResult ) => { - logger.warn(`[${requestId}] ${failure.message}; ending the turn`, { + logger.warn(`${failure.message}; ending the turn`, { + requestId, streamId, code: failure.code, ...(failure instanceof StreamReplayBudgetExhaustedError @@ -294,7 +297,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS ) await requestExplicitStreamAbort({ streamId, userId, chatId }) } catch (error) { - logger.warn(`[${requestId}] Worker stop after a turn failure was not delivered`, { + logger.warn('Worker stop after a turn failure was not delivered', { + requestId, streamId, error: getErrorMessage(error), }) @@ -512,7 +516,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS cancelReason = recordCancelled(getErrorMessage(error)) } if (publisher.clientDisconnected) { - logger.info(`[${requestId}] Stream errored after client disconnect`, { + logger.info('Stream errored after client disconnect', { + requestId, error: getErrorMessage(error, 'Stream error'), }) } @@ -520,7 +525,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS // cancel — it isn't an "unexpected" failure then, and the // error-level log pollutes alerting on normal Stop presses. const logFn = outcome === RequestTraceV1Outcome.cancelled ? logger.warn : logger.error - logFn.call(logger, `[${requestId}] Orchestration ended with ${outcome}:`, error) + logFn.call(logger, `Orchestration ended with ${outcome}:`, error, { requestId }) const syntheticResult = { success: false as const, @@ -544,7 +549,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS try { await publisher.close() } catch (error) { - logger.warn(`[${requestId}] Failed to flush stream persistence during close`, { + logger.warn('Failed to flush stream persistence during close', { + requestId, error: getErrorMessage(error), }) } @@ -622,7 +628,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS try { activeOtelRoot.finish(rootOutcome, rootError, cancelReason) } catch (finishError) { - logger.error(`[${requestId}] activeOtelRoot.finish threw; force-ending root span`, { + logger.error('activeOtelRoot.finish threw; force-ending root span', { + requestId, error: getErrorMessage(finishError), }) try { @@ -718,7 +725,7 @@ function fireTitleGeneration(params: { publishChatStatusChanged({ workspaceId, organizationId, userId }, { chatId, type: 'renamed' }) }) .catch((error) => { - logger.error(`[${requestId}] Title generation failed:`, error) + logger.error('Title generation failed:', error, { requestId }) }) } diff --git a/apps/sim/lib/mothership/tools/server/settings-sim-links.ts b/apps/sim/lib/mothership/tools/server/settings-sim-links.ts index bfb1ff02b08..2d51a2cd703 100644 --- a/apps/sim/lib/mothership/tools/server/settings-sim-links.ts +++ b/apps/sim/lib/mothership/tools/server/settings-sim-links.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { listSimLinksQuerySchema } from '@/lib/api/contracts/sim-links' -import { presentSimLink } from '@/lib/api/server/sim-link-presenters' +import { presentSimLink, presentSimLinkPage } from '@/lib/api/server/sim-link-presenters' import type { SettingsContext } from '@/lib/mothership/application/settings-context' import { settingsOperation, @@ -22,11 +22,12 @@ export async function readSettingsSimLinks( context: SettingsContext, query: z.output = {} ) { - const page = await listSimLinks.execute({ - principal: context.principal, - input: { ...query, organizationId: settingsOrganizationId(context) }, - }) - return { links: page.links.map(presentSimLink), nextCursor: page.nextCursor } + return presentSimLinkPage( + await listSimLinks.execute({ + principal: context.principal, + input: { ...query, organizationId: settingsOrganizationId(context) }, + }) + ) } /** Per-link edit, unlist and restrict rules, audit and the rollout flag stay in the use cases. */ diff --git a/apps/sim/lib/navigation/auth-surfaces.ts b/apps/sim/lib/navigation/auth-surfaces.ts index ca886bbde0b..8b8353ff653 100644 --- a/apps/sim/lib/navigation/auth-surfaces.ts +++ b/apps/sim/lib/navigation/auth-surfaces.ts @@ -10,6 +10,7 @@ const AUTH_SURFACE_ROOTS = [ '/oauth-error', '/invite', '/enterprise/claim', + '/marketplace', '/unsubscribe', '/desktop/auth', '/desktop/done', diff --git a/apps/sim/lib/navigation/resolve-app-entry.test.ts b/apps/sim/lib/navigation/resolve-app-entry.test.ts index 09be7da411c..823d0018f6b 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.test.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.test.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/organizations/surface', () => ({ import { resolveAppEntryPath, resolveOrganizationEntryPath, + resolveRolledOutOrganizationId, } from '@/lib/navigation/resolve-app-entry' const mockSearchAvailable = knowledgeAvailabilityMockFns.mockIsKnowledgeMemberAccessAvailable @@ -69,3 +70,17 @@ describe('resolveOrganizationEntryPath', () => { await expect(resolveOrganizationEntryPath({ user: { id: 'viewer' } })).resolves.toBeNull() }) }) + +describe('resolveRolledOutOrganizationId', () => { + beforeEach(() => { + mockSearchAvailable.mockResolvedValue(true) + }) + + it('names the organization only when its surface would keep the viewer', async () => { + mockResolveOrganizationLanding.mockResolvedValue('org-1') + await expect(resolveRolledOutOrganizationId({ user: { id: 'viewer' } })).resolves.toBe('org-1') + + mockSearchAvailable.mockResolvedValue(false) + await expect(resolveRolledOutOrganizationId({ user: { id: 'viewer' } })).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/navigation/resolve-app-entry.ts b/apps/sim/lib/navigation/resolve-app-entry.ts index 28c94429119..5f9c8e47bd9 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.ts @@ -7,16 +7,26 @@ interface EntrySession { user: { id: string } } -/** Returns a destination only after checking the viewer's membership and organization rollout. */ -export async function resolveOrganizationEntryPath(session: EntrySession): Promise { +/** + * The viewer's organization, once membership and the organization's rollout both check + * out. The organization surface sends a viewer to the workspace picker only when that + * rollout is off, so forwarding the picker's viewers on this answer cannot loop. + */ +export async function resolveRolledOutOrganizationId( + session: EntrySession +): Promise { const organizationId = await resolveOrganizationLanding( session.user.id, getActiveOrganizationId(session) ) if (!organizationId) return null - return (await isKnowledgeMemberAccessAvailable({ organizationId })) - ? organizationRoutes(organizationId).home - : null + return (await isKnowledgeMemberAccessAvailable({ organizationId })) ? organizationId : null +} + +/** Returns a destination only after checking the viewer's membership and organization rollout. */ +export async function resolveOrganizationEntryPath(session: EntrySession): Promise { + const organizationId = await resolveRolledOutOrganizationId(session) + return organizationId ? organizationRoutes(organizationId).home : null } /** diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 734c61efe60..a203379ecd1 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -498,6 +498,7 @@ export interface ServiceAccountTokenResult { cloudId?: string /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ domain?: string + providerWorkspaceId?: string /** Salesforce or NetSuite — the provider origin the token must be used against. */ instanceUrl?: string /** @@ -819,6 +820,7 @@ export async function resolveServiceAccountToken( return { accessToken: secret.apiToken, domain: secret.domain, + providerWorkspaceId: secret.providerWorkspaceId, ...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}), } } @@ -869,7 +871,7 @@ async function getCredentialByAccountId(requestId: string, accountId: string, us .limit(1) if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) + logger.warn('Credential not found', { requestId }) return undefined } @@ -885,7 +887,7 @@ async function getCredentialByAccountId(requestId: string, accountId: string, us export async function getCredential(requestId: string, credentialId: string, userId: string) { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { - logger.warn(`[${requestId}] Credential is not an OAuth credential`) + logger.warn('Credential is not an OAuth credential', { requestId }) return undefined } return getCredentialByAccountId(requestId, resolved.accountId, userId) @@ -1597,8 +1599,10 @@ async function performCoalescedRefresh({ } /** Starts a refresh ahead of expiry; callers in the window share it through the refresh's own single-flight. */ -function refreshInBackground(options: CoalescedRefreshOptions, logPrefix: string): void { - logger.debug(`${logPrefix}Refreshing ahead of expiry; using the still-valid access token`) +function refreshInBackground(options: CoalescedRefreshOptions): void { + logger.debug('Refreshing ahead of expiry; using the still-valid access token', { + requestId: options.requestId, + }) // The refresh settles and logs its own failures; this catches only a setup error before it starts. performCoalescedRefresh(options).catch((error: unknown) => { logger.error('Background token refresh failed to start', { @@ -1635,9 +1639,8 @@ async function resolveStoredAccessToken( refreshToken: plan.refreshToken, chain: stored, } - const prefix = refresh.requestId ? `[${refresh.requestId}] ` : '' if (plan.kind === 'refresh-ahead') { - refreshInBackground(options, prefix) + refreshInBackground(options) return { kind: 'token', accessToken: stored.accessToken, refreshed: false } } const fresh = await waitWithAbort(performCoalescedRefresh(options), signal) @@ -1645,7 +1648,9 @@ async function resolveStoredAccessToken( return { kind: 'token', accessToken: fresh.accessToken, refreshed: true } } if (plan.kind === 'refresh-or-reuse' && stored.accessToken) { - logger.info(`${prefix}Refresh unavailable; reusing still-valid access token`) + logger.info('Refresh unavailable; reusing still-valid access token', { + requestId: refresh.requestId, + }) return { kind: 'token', accessToken: stored.accessToken, refreshed: false } } return { kind: 'failed', outcome: fresh } @@ -1724,7 +1729,7 @@ export async function resolveCredentialTokenBundle( } if (resolved.credentialType === 'service_account' && resolved.credentialId) { - logger.info(`[${requestId}] Using service account token for credential`) + logger.info('Using service account token for credential', { requestId }) return resolveServiceAccountToken( resolved.credentialId, resolved.providerId, @@ -1761,10 +1766,10 @@ export async function resolveCredentialTokenBundle( if (result.kind === 'failed') return null if (!result.accessToken) { // We have no access token and either no refresh token or not eligible to refresh - logger.error(`[${requestId}] Missing access token for credential`) + logger.error('Missing access token for credential', { requestId }) return null } - if (!result.refreshed) logger.info(`[${requestId}] Access token is valid for credential`) + if (!result.refreshed) logger.debug('Access token is valid for credential', { requestId }) return { accessToken: result.accessToken } } @@ -1817,7 +1822,7 @@ export async function refreshTokenIfNeeded( throw new Error('OAuth access token expired and cannot be refreshed; reconnect the account') } if (result.kind === 'token') { - if (!result.refreshed) logger.info(`[${requestId}] Access token is valid`) + if (!result.refreshed) logger.debug('Access token is valid', { requestId }) /** A row storing neither token returns its empty token as before; callers decide how to fail. */ return { accessToken: result.accessToken ?? credential.accessToken, diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 07da153895d..cf8fb4d84c5 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { AirtableIcon, + ApiIcon, AsanaIcon, AtlassianIcon, AttioIcon, @@ -21,6 +22,7 @@ import { DocuSignIcon, DropboxIcon, FivetranIcon, + GaugeIcon, GithubIcon, GmailIcon, GoogleAdsIcon, @@ -1588,6 +1590,40 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'hubspot', }, + jama: { + name: 'Jama Connect', + icon: ApiIcon, + services: { + jama: { + name: 'Jama Connect', + description: 'Connect your Jama Connect account.', + providerId: 'jama', + serviceAccountProviderId: 'jama-service-account', + icon: ApiIcon, + baseProviderIcon: ApiIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'jama', + }, + flow: { + name: 'Flow Engineering', + icon: ApiIcon, + services: { + flow: { + name: 'Flow Engineering', + description: 'Connect your Flow Engineering account.', + providerId: 'flow', + serviceAccountProviderId: 'flow-service-account', + icon: ApiIcon, + baseProviderIcon: ApiIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'flow', + }, coda: { name: 'Coda', icon: CodaIcon, @@ -1605,6 +1641,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'coda', }, + gauge: { + name: 'Gauge', + icon: GaugeIcon, + services: { + gauge: { + name: 'Gauge', + description: 'Track AI visibility and manage Gauge prompts, topics, and tags.', + providerId: 'gauge', + serviceAccountProviderId: 'gauge-service-account', + icon: GaugeIcon, + baseProviderIcon: GaugeIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'gauge', + }, harmonic: { name: 'Harmonic', icon: HarmonicIcon, diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 01b6e005f9b..74e41788ed8 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -256,14 +256,15 @@ export async function completeOAuthCredentialToken(params: { return { ok: true, token: buildOAuthTokenPayload(credential, accessToken) } } catch (error) { if (error instanceof CredentialRevokedError) { - logger.warn(`[${requestId}] OAuth credential revoked by provider; reconnect required`, { + logger.warn('OAuth credential revoked by provider; reconnect required', { + requestId, credentialId: resolvedCredentialId, providerId: error.provider?.providerId, errorCode: error.provider?.errorCode, }) return { ok: false, status: 401, code: OAUTH_CREDENTIAL_REVOKED, error: error.message } } - logger.error(`[${requestId}] Failed to refresh access token:`, error) + logger.error('Failed to refresh access token:', error, { requestId }) return { ok: false, status: 401, error: 'Failed to refresh access token' } } } @@ -389,13 +390,14 @@ export async function resolveCredentialToken( credentialType: 'service_account', cloudId: result.cloudId, domain: result.domain, + providerWorkspaceId: result.providerWorkspaceId, instanceUrl: result.instanceUrl, apiDomain: result.apiDomain, authStyle: result.authStyle, }, } } catch (error) { - logger.error(`[${requestId}] Service account token error:`, error) + logger.error('Service account token error:', error, { requestId }) if (error instanceof TokenServiceAccountValidationError) { // Classified provider outages are infra failures, not bad credentials. if (error.code === 'provider_unavailable') { @@ -461,7 +463,7 @@ export async function resolveCredentialToken( auditRequest, }) } catch (error) { - logger.error(`[${requestId}] Error getting access token`, error) + logger.error('Error getting access token', error, { requestId }) return { ok: false, status: 500, error: 'Internal server error' } } } @@ -550,7 +552,7 @@ export async function resolveCredentialAccessToken( const toolMetadata = getToolMetadata(toolId) if (!toolMetadata?.oauth?.required) { - logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) + logger.error('Tool is not configured for managed OAuth', { requestId, toolId }) return { ok: false, status: 500, @@ -565,7 +567,8 @@ export async function resolveCredentialAccessToken( toolMetadata.oauth.requiredScopes ?? getDefaultToolScopesForProvider(toolMetadata.oauth.provider) if (requiredScopes.length === 0 && toolMetadata.oauth.requiredScopes === undefined) { - logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, { + logger.error('Tool has no trusted OAuth scope policy', { + requestId, toolId, providerId: toolMetadata.oauth.provider, }) @@ -614,7 +617,8 @@ export async function resolveCredentialAccessToken( } } catch (error) { if (error instanceof ManagedOAuthCredentialError) { - logger.warn(`[${requestId}] Managed OAuth credential rejected`, { + logger.warn('Managed OAuth credential rejected', { + requestId, credentialId: resolved.credentialId, code: error.code, }) diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index c566c2de3c7..1d5ed19e006 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -105,6 +105,7 @@ export type OAuthProvider = | 'hubspot' | 'harmonic' | 'coda' + | 'gauge' | 'salesforce' | 'linkedin' | 'instagram' @@ -118,6 +119,8 @@ export type OAuthProvider = | 'zoho-desk' export type OAuthService = + | 'jama' + | 'flow' | 'oracledb' | 'oracle-epm' | 'oracle-fusion' @@ -179,6 +182,7 @@ export type OAuthService = | 'hubspot' | 'harmonic' | 'coda' + | 'gauge' | 'salesforce' | 'linkedin' | 'instagram' diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index c97b8cf1807..da534387b83 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -20,12 +20,13 @@ import { } from '@/lib/organizations/chat-access' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { getUserPermissionConfigForOrganization, isOrganizationPermissionRegimeActive, } from '@/lib/permission-groups/resolve.server' -export interface OrganizationSurfaceOrganization { +interface OrganizationSurfaceOrganization { id: string name: string slug: string @@ -57,6 +58,13 @@ export interface OrganizationSurfaceContext extends OrganizationChatAccess { deployment: DeploymentShape } +function canConnectSearchIntegrations(config: PermissionGroupConfig | null): boolean { + return ( + !capabilityDeniedBy('integrations.manage', config) && + !capabilityDeniedBy('knowledge.use', config) + ) +} + /** * Resolves the surface context from membership in the organization named by the * route. Session active-organization state is intentionally not consulted: it @@ -125,9 +133,7 @@ async function resolveOrganizationSurfaceContext( isAdmin: access.isAdmin, canInviteMembers: access.isAdmin && !isInvitationsDisabled && !capabilityDeniedBy('invitations.send', config), - canConnectSearchIntegrations: - !capabilityDeniedBy('integrations.manage', config) && - !capabilityDeniedBy('knowledge.use', config), + canConnectSearchIntegrations: canConnectSearchIntegrations(config), canUsePersonalApiKeys: !capabilityDeniedBy('personal_api_key.use', config) && !capabilityDeniedBy('api_keys.manage', config), diff --git a/apps/sim/lib/people/feature-flag.ts b/apps/sim/lib/people/feature-flag.ts index 7054328e162..9331e5dac91 100644 --- a/apps/sim/lib/people/feature-flag.ts +++ b/apps/sim/lib/people/feature-flag.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' @@ -20,10 +21,13 @@ const logger = createLogger('People') /** * Whether to show the People page to a member with `role`: it is on for `organizationId`, and its * directory is open to them — administrators always, other members unless their permission group - * withholds `organization.member_directory`, the capability its reads enforce. For navigation - * only, so a lookup that fails hides People rather than failing the page around it. + * withholds `organization.member_directory`, the capability its reads enforce. For navigation and + * the page gate, so a lookup that fails hides People rather than failing the page around it. */ -export async function canBrowsePeople(organizationId: string, role: string): Promise { +export const canBrowsePeople = cache(async function canBrowsePeople( + organizationId: string, + role: string +): Promise { try { if (!(await isPeopleEnabled(organizationId))) return false return ( @@ -37,4 +41,4 @@ export async function canBrowsePeople(organizationId: string, role: string): Pro }) return false } -} +}) diff --git a/apps/sim/lib/realtime/event-stream-route.ts b/apps/sim/lib/realtime/event-stream-route.ts index ad4fd2e8a82..72fa358bc83 100644 --- a/apps/sim/lib/realtime/event-stream-route.ts +++ b/apps/sim/lib/realtime/event-stream-route.ts @@ -41,7 +41,7 @@ export function createEventStreamResponse( ): NextResponse { const { requestId, streamId, fromEventId, getLatestEventId, readEventsSince, label } = options - logger.info(`[${requestId}] ${label} event stream opened`, { streamId, fromEventId }) + logger.info(`${label} event stream opened`, { requestId, streamId, fromEventId }) const encoder = new TextEncoder() let closed = false @@ -137,7 +137,8 @@ export function createEventStreamResponse( } catch {} } } catch (error) { - logger.error(`[${requestId}] ${label} event stream error`, { + logger.error(`${label} event stream error`, { + requestId, streamId, error: toError(error).message, }) @@ -150,7 +151,7 @@ export function createEventStreamResponse( }, cancel() { closed = true - logger.info(`[${requestId}] Client disconnected from ${label} event stream`, { streamId }) + logger.info(`Client disconnected from ${label} event stream`, { requestId, streamId }) }, }) diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index 396f57e1173..8a929d94219 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -73,7 +73,6 @@ describe('selector manifest', () => { (attachment) => attachment.destination !== 'fixed' ) - expect(preparedDestinations).toHaveLength(14) for (const attachment of preparedDestinations) { expect(attachment.destination).toEqual( expect.objectContaining({ diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 980ce5bbbbd..abcdc35913c 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -189,6 +189,40 @@ export const selectorManifest = { listMode: 'paginated', detail: true, }), + 'gauge.organizations': providerSelector([], { detail: true, unknownDetail: true }), + 'gauge.categories': providerSelector(['organization'], { + readiness: { all: ['oauthCredential', 'organization'] }, + sourceFields: { organization: ['org'] }, + detail: true, + unknownDetail: true, + }), + 'gauge.topics': providerSelector(['organization', 'category'], { + readiness: { all: ['oauthCredential', 'organization', 'category'] }, + sourceFields: { organization: ['org'] }, + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), + 'gauge.prompts': providerSelector(['organization', 'category'], { + readiness: { all: ['oauthCredential', 'organization', 'category'] }, + sourceFields: { organization: ['org'] }, + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), + 'gauge.tags': providerSelector(['organization'], { + readiness: { all: ['oauthCredential', 'organization'] }, + sourceFields: { organization: ['org'] }, + detail: true, + unknownDetail: true, + }), + 'gauge.answers': providerSelector(['organization', 'category'], { + readiness: { all: ['oauthCredential', 'organization', 'category'] }, + sourceFields: { organization: ['org'] }, + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), 'fivetran.groups': providerSelector([], { listMode: 'paginated', detail: true, @@ -218,6 +252,19 @@ export const selectorManifest = { readiness: { all: ['oauthCredential', 'spreadsheetId'] }, }), 'harmonic.savedSearches': providerSelector([], { detail: true, unknownDetail: true }), + 'jama.projects': providerSelector([], { + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), + 'jama.itemTypes': providerSelector(['projectId'], { listMode: 'paginated' }), + 'flow.projects': providerSelector(), + 'flow.categories': providerSelector(['projectId'], { + readiness: { all: ['oauthCredential', 'projectId'] }, + }), + 'flow.branches': providerSelector(['projectId'], { + readiness: { all: ['oauthCredential', 'projectId'] }, + }), 'helpscout.mailboxes': providerSelector([], { listMode: 'paginated', detail: true }), 'helpscout.assignees': providerSelector([], { listMode: 'paginated', detail: true }), 'hubspot.lists': providerSelector([], { listMode: 'paginated', search: true, detail: true }), diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.ts index 97669ff8a8f..109fd5c6750 100644 --- a/apps/sim/lib/selectors/server/providers/credential-bundle.ts +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.ts @@ -69,6 +69,7 @@ export async function resolveSelectorCredentialBundle(input: { input.protectedValues.add(bundle.accessToken) input.protectedValues.add(bundle.cloudId, 'reference') input.protectedValues.add(bundle.domain, 'reference') + input.protectedValues.add(bundle.providerWorkspaceId, 'reference') input.protectedValues.add(bundle.instanceUrl, 'reference') input.protectedValues.add(bundle.apiDomain, 'reference') if (input.providerId) input.recordCredentialUse?.(credential.providerId ?? input.providerId) diff --git a/apps/sim/lib/selectors/server/providers/flow.ts b/apps/sim/lib/selectors/server/providers/flow.ts new file mode 100644 index 00000000000..6c1508e64db --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/flow.ts @@ -0,0 +1,131 @@ +import { truncate } from '@sim/utils/string' +import { z } from 'zod' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { flowHeaders, normalizeFlowOrigin, normalizeFlowWorkspaceId } from '@/lib/flow' +import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { selectorProviderStatusError } from '@/lib/selectors/server/providers/http' +import { + definePreparedSelectorAttachment, + type ExecuteServerSelectorArgs, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +type FlowSelectorKey = Extract + +interface FlowDestination { + domain: string + accessToken: string + providerWorkspaceId: string +} + +const namedItemSchema = z.object({ + id: z.string().min(1).max(512), + name: z.string(), +}) +const namedItemsSchema = z.array(namedItemSchema).max(MAX_SELECTOR_OPTIONS) +const PROJECT_LIMIT = 100 + +async function prepareFlowDestination(args: ExecuteServerSelectorArgs): Promise { + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + if (!bundle.domain || !bundle.providerWorkspaceId) throw new SelectorConnectionUnavailableError() + try { + return { + domain: normalizeFlowOrigin(bundle.domain), + providerWorkspaceId: normalizeFlowWorkspaceId(bundle.providerWorkspaceId), + accessToken: bundle.accessToken, + } + } catch { + throw new SelectorConnectionUnavailableError() + } +} + +async function executeFlow(args: ExecuteServerSelectorArgs, destination: FlowDestination) { + let path: string + let projectId: string | undefined + if (args.selectorKey === 'flow.projects') { + path = `/project?limit=${PROJECT_LIMIT}` + } else { + if (!args.context.projectId) throw new SelectorContextUnavailableError() + try { + projectId = safeUrlPathSegment(args.context.projectId, 'projectId') + } catch { + throw new SelectorContextUnavailableError() + } + path = `/project/${projectId}/${args.selectorKey === 'flow.categories' ? 'data-model' : 'branch'}` + } + + try { + const response = await secureFetchWithValidation(`${destination.domain}${path}`, { + profile: 'configuredEndpoint', + method: 'GET', + headers: flowHeaders(destination), + timeout: 15_000, + maxResponseBytes: 4 * 1024 * 1024, + maxRedirects: 0, + signal: args.signal, + logUrlValidationDetails: false, + }) + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + throw selectorProviderStatusError(response.status) + } + const parsed = namedItemsSchema.safeParse(await response.json()) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + const items: SafeSelectorOption[] = parsed.data.map((item) => ({ + id: item.id, + label: truncate(item.name || item.id, 200), + })) + const truncated = args.selectorKey === 'flow.projects' && items.length >= PROJECT_LIMIT + return flatSelectorResult( + args.request, + items, + true, + truncated ? { truncated: { reason: 'provider-cap', limit: PROJECT_LIMIT } } : undefined + ) + } catch (error) { + if (args.signal?.aborted) throw error + if (error instanceof SelectorConnectionUnavailableError) throw error + if (error instanceof SelectorOptionsUnavailableError) throw error + throw new SelectorOptionsUnavailableError() + } +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['flow'], +} as const + +export const flowSelectorAttachments = { + 'flow.projects': definePreparedSelectorAttachment({ + credential, + integrationBlockTypes: ['flow'], + destination: { kind: 'credential-bound', prepare: prepareFlowDestination }, + execute: executeFlow, + }), + 'flow.categories': definePreparedSelectorAttachment({ + credential, + integrationBlockTypes: ['flow'], + destination: { kind: 'credential-bound', prepare: prepareFlowDestination }, + execute: executeFlow, + }), + 'flow.branches': definePreparedSelectorAttachment({ + credential, + integrationBlockTypes: ['flow'], + destination: { kind: 'credential-bound', prepare: prepareFlowDestination }, + execute: executeFlow, + }), +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/gauge.test.ts b/apps/sim/lib/selectors/server/providers/gauge.test.ts new file mode 100644 index 00000000000..0c074d6b6be --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/gauge.test.ts @@ -0,0 +1,152 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { + selectorCredentialBundleMock, + selectorCredentialBundleMockFns, +} from '@sim/testing/mocks/selector-credential-bundle.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { gaugeSelectorAttachments } from '@/lib/selectors/server/providers/gauge' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' +import type { SelectorContext, SelectorRequest } from '@/lib/selectors/types' + +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => selectorCredentialBundleMock) + +function args( + selectorKey: ServerSelectorKey, + request: SelectorRequest, + context: SelectorContext = {} +): ExecuteServerSelectorArgs { + return { + selectorKey, + context: { oauthCredential: 'credential-example', ...context }, + request, + scope: { kind: 'workspace', workspaceId: 'workspace-example' }, + workspaceId: 'workspace-example', + principal: { kind: 'session', userId: 'user-example', sessionId: 'session-example' }, + requesterUserId: 'user-example', + credential: { suppliedId: 'credential-example' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} + +describe('Gauge server resource selectors', () => { + beforeEach(() => { + selectorCredentialBundleMockFns.mockResolveSelectorCredentialBundle.mockResolvedValue({ + accessToken: 'server-only-token', + }) + }) + + it('preserves opaque provider cursors without eagerly reading all tracked prompts', async () => { + const cursor = 'previous/page&trackingType=visibility é' + const nextCursor = 'next/page'.repeat(512) + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + data: [{ id: 'prm_example', text: 'Example question' }], + nextCursor, + }) + ) + vi.stubGlobal('fetch', fetch) + + const result = await gaugeSelectorAttachments['gauge.prompts'].execute( + args( + 'gauge.prompts', + { kind: 'list', cursor }, + { organization: 'example', category: 'category' } + ) + ) + + expect(result).toEqual({ + kind: 'list', + items: [{ id: 'prm_example', label: 'Example question' }], + nextCursor, + }) + expect(fetch).toHaveBeenCalledTimes(1) + const requested = new URL(String(fetch.mock.calls[0]?.[0])) + expect(requested.searchParams.get('cursor')).toBe(cursor) + expect(requested.searchParams.get('trackingType')).toBe('all') + }) + + it('loads answer options when the provider rejects pages larger than 100', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const limit = Number(new URL(String(input)).searchParams.get('limit')) + if (limit > 100) { + return jsonResponse( + { + error: { + code: 'invalid_request', + message: 'limit must be between 1 and 100', + field: 'limit', + }, + }, + 400 + ) + } + return jsonResponse({ + data: [ + { + id: 'answer-example', + text: 'Example answer', + promptId: 'prm_example', + model: 'example-model', + day: '2026-11-01', + }, + ], + nextCursor: null, + }) + }) + ) + + const result = await gaugeSelectorAttachments['gauge.answers'].execute( + args('gauge.answers', { kind: 'list' }, { organization: 'example', category: 'category' }) + ) + + expect(result).toMatchObject({ kind: 'list', items: [{ id: 'answer-example' }] }) + }) + + it('excludes organizations without Chat and projects only resource option fields', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + data: [ + { + id: 'org-chat', + name: 'Chat workspace', + products: { chat: true }, + private: 'discard', + }, + { id: 'org-agents', name: 'Agents workspace', products: { chat: false } }, + ], + }) + ) + ) + + const result = await gaugeSelectorAttachments['gauge.organizations'].execute( + args('gauge.organizations', { kind: 'list' }) + ) + + expect(result).toEqual({ kind: 'list', items: [{ id: 'org-chat', label: 'Chat workspace' }] }) + }) + + it('rejects traversal in organization or category before contacting Gauge', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + gaugeSelectorAttachments['gauge.topics'].execute( + args('gauge.topics', { kind: 'list' }, { organization: '..', category: 'category' }) + ) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + await expect( + gaugeSelectorAttachments['gauge.topics'].execute( + args('gauge.topics', { kind: 'list' }, { organization: 'example', category: 'a/b' }) + ) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/gauge.ts b/apps/sim/lib/selectors/server/providers/gauge.ts new file mode 100644 index 00000000000..4dc59d1b4de --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/gauge.ts @@ -0,0 +1,167 @@ +import { truncate } from '@sim/utils/string' +import { z } from 'zod' +import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { + fetchProviderJson, + fetchProviderJsonWithStatus, +} from '@/lib/selectors/server/providers/http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachment, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +type GaugeSelectorKey = Extract + +const GAUGE_API_BASE = 'https://app.withgauge.com/api/v1' +const GAUGE_PAGE_SIZE = 200 + +const namedSchema = z.object({ id: z.string().min(1).max(512), name: z.string() }) +const organizationSchema = namedSchema.extend({ products: z.object({ chat: z.boolean() }) }) +const topicSchema = z.object({ id: z.string().min(1).max(512), title: z.string() }) +const promptSchema = z.object({ id: z.string().min(1).max(512), text: z.string() }) +const answerSchema = promptSchema.extend({ + model: z.string(), + day: z.string(), + promptId: z.string().min(1).max(512), +}) + +function segment(value: string | undefined, field: string): string { + if (!value?.trim()) throw new SelectorContextUnavailableError() + try { + return safeUrlPathSegment(value, field) + } catch { + throw new SelectorContextUnavailableError() + } +} + +function organizationPath(args: ExecuteServerSelectorArgs): string { + return `/orgs/${segment(args.context.organization, 'organization')}` +} + +function categoryPath(args: ExecuteServerSelectorArgs): string { + return `${organizationPath(args)}/categories/${segment(args.context.category, 'category')}` +} + +function namedOption(item: z.infer): SafeSelectorOption { + return { id: item.id, label: truncate(item.name.trim() || item.id, 200) } +} + +function answerOption(item: z.infer): SafeSelectorOption { + return { + id: item.id, + label: truncate(`${item.day} · ${item.model} · ${item.text}`, 200), + meta: { promptId: item.promptId, model: item.model, day: item.day }, + } +} + +function resourceAttachment(input: { + path(args: ExecuteServerSelectorArgs): string + schema: T + toOption(item: z.infer): SafeSelectorOption | null + paginated?: boolean + pageSize?: number + allTrackingTypes?: boolean +}): ServerSelectorAttachment { + return { + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['gauge'] }, + integrationBlockTypes: ['gauge'], + destination: 'fixed', + execute: async (args) => { + const pageSize = input.pageSize ?? GAUGE_PAGE_SIZE + const path = input.path(args) + const url = new URL(`${GAUGE_API_BASE}${path}`) + if (args.request.kind === 'detail') { + url.pathname += `/${segment(args.request.id, 'resourceId')}` + } else if (input.paginated) { + url.searchParams.set('limit', String(pageSize)) + if (input.allTrackingTypes) url.searchParams.set('trackingType', 'all') + if (args.request.cursor !== undefined) { + url.searchParams.set('cursor', args.request.cursor) + } + } + + const { accessToken } = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + const request = { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + signal: args.signal, + } + if (args.request.kind === 'detail') { + const result = await fetchProviderJsonWithStatus(url, request, { + passthroughStatuses: [404], + }) + if (!result.ok) return detailSelectorResult(null) + const parsed = input.schema.safeParse(result.data) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return detailSelectorResult(input.toOption(parsed.data)) + } + + const body = await fetchProviderJson(url, request) + const parsed = z + .object({ + data: z.array(input.schema).max(input.paginated ? pageSize : MAX_SELECTOR_OPTIONS), + nextCursor: z.string().nullish(), + }) + .safeParse(body) + if (!parsed.success || (!input.paginated && parsed.data.nextCursor)) { + throw new SelectorOptionsUnavailableError() + } + const items = parsed.data.data + .map(input.toOption) + .filter((option): option is SafeSelectorOption => option !== null) + return listSelectorResult(items, parsed.data.nextCursor ?? undefined) + }, + } +} + +export const gaugeSelectorAttachments = { + 'gauge.organizations': resourceAttachment({ + path: () => '/orgs', + schema: organizationSchema, + toOption: (item) => (item.products.chat ? namedOption(item) : null), + }), + 'gauge.categories': resourceAttachment({ + path: (args) => `${organizationPath(args)}/categories`, + schema: namedSchema, + toOption: namedOption, + }), + 'gauge.topics': resourceAttachment({ + path: (args) => `${categoryPath(args)}/topics`, + schema: topicSchema, + toOption: (item) => ({ id: item.id, label: truncate(item.title.trim() || item.id, 200) }), + paginated: true, + }), + 'gauge.prompts': resourceAttachment({ + path: (args) => `${categoryPath(args)}/prompts`, + schema: promptSchema, + toOption: (item) => ({ id: item.id, label: truncate(item.text.trim() || item.id, 200) }), + paginated: true, + allTrackingTypes: true, + }), + 'gauge.tags': resourceAttachment({ + path: (args) => `${organizationPath(args)}/tags`, + schema: namedSchema, + toOption: namedOption, + }), + 'gauge.answers': resourceAttachment({ + path: (args) => `${categoryPath(args)}/answers`, + schema: answerSchema, + toOption: answerOption, + paginated: true, + pageSize: 100, + allTrackingTypes: true, + }), +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/jama.test.ts b/apps/sim/lib/selectors/server/providers/jama.test.ts new file mode 100644 index 00000000000..ff49e66366e --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/jama.test.ts @@ -0,0 +1,112 @@ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { + selectorCredentialBundleMock, + selectorCredentialBundleMockFns, +} from '@sim/testing/mocks/selector-credential-bundle.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => selectorCredentialBundleMock) + +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { jamaSelectorAttachments } from '@/lib/selectors/server/providers/jama' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +const fetchProvider = inputValidationMockFns.mockSecureFetchWithValidation +const resolveCredential = selectorCredentialBundleMockFns.mockResolveSelectorCredentialBundle + +function args(request: ExecuteServerSelectorArgs['request']): ExecuteServerSelectorArgs { + return { + selectorKey: 'jama.projects', + context: { oauthCredential: 'credential-1' }, + request, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: createSessionPrincipal(), + requesterUserId: 'user-1', + credential: { suppliedId: 'credential-1' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} + +function page(startIndex: number, totalResults: number, data: unknown[]) { + return Response.json({ + data, + meta: { pageInfo: { startIndex, totalResults, resultCount: data.length } }, + }) +} + +describe('Jama selector pagination and credential binding', () => { + beforeEach(() => { + resolveCredential.mockResolvedValue({ + accessToken: 'private-token', + instanceUrl: 'https://tenant.example.com/jama', + }) + }) + + it('advances by the returned count and omits private provider fields', async () => { + fetchProvider.mockResolvedValueOnce( + page(50, 53, [ + { id: 71, name: 'Requirements', privateToken: 'provider-private' }, + { id: 72, fields: { name: 'Verification' } }, + ]) + ) + const result = await jamaSelectorAttachments['jama.projects'].execute( + args({ kind: 'list', cursor: '50' }) + ) + expect(result).toEqual({ + kind: 'list', + items: [ + { id: '71', label: 'Requirements' }, + { id: '72', label: 'Verification' }, + ], + nextCursor: '52', + }) + }) + + it.each(['-1', '2.5', 'https://attacker.invalid', '9007199254740992'])( + 'rejects an invalid pagination cursor %s', + async (cursor) => { + await expect( + jamaSelectorAttachments['jama.projects'].execute(args({ kind: 'list', cursor })) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + } + ) + + it('stops on an empty page and rejects provider pagination that would loop', async () => { + fetchProvider.mockResolvedValueOnce(page(50, 70, [])) + expect( + await jamaSelectorAttachments['jama.projects'].execute(args({ kind: 'list', cursor: '50' })) + ).toEqual({ kind: 'list', items: [] }) + fetchProvider.mockResolvedValueOnce(page(0, 70, [{ id: 1, name: 'Wrong page' }])) + await expect( + jamaSelectorAttachments['jama.projects'].execute(args({ kind: 'list', cursor: '50' })) + ).rejects.toBeInstanceOf(SelectorOptionsUnavailableError) + }) + + it('does not substitute user context when the credential has no instance', async () => { + resolveCredential.mockResolvedValueOnce({ accessToken: 'private-token' }) + await expect( + jamaSelectorAttachments['jama.projects'].execute(args({ kind: 'list' })) + ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) + }) + + it('rejects a parent project that could change the provider path', async () => { + const input = args({ kind: 'list' }) + input.selectorKey = 'jama.itemTypes' + input.context.projectId = '../users' + await expect(jamaSelectorAttachments['jama.itemTypes'].execute(input)).rejects.toBeInstanceOf( + SelectorContextUnavailableError + ) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/jama.ts b/apps/sim/lib/selectors/server/providers/jama.ts new file mode 100644 index 00000000000..664e2584c8e --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/jama.ts @@ -0,0 +1,193 @@ +import { toRecord } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { z } from 'zod' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { normalizeJamaInstanceUrl } from '@/lib/jama/client' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { selectorProviderStatusError } from '@/lib/selectors/server/providers/http' +import { + definePreparedSelectorAttachment, + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { jamaId, jamaUrl } from '@/tools/jama/utils' + +type JamaSelectorKey = Extract + +const PAGE_SIZE = 50 +const credential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['jama'] } as const + +interface JamaDestination { + accessToken: string + instanceUrl: string +} + +const resourceSchema = z.object({ + id: z.number().int().positive(), + name: z.string().optional(), + display: z.string().optional(), + fields: z.object({ name: z.string().optional() }).optional(), +}) + +const pageSchema = z.object({ + data: z.array(resourceSchema).max(PAGE_SIZE), + meta: z.object({ + pageInfo: z.object({ + startIndex: z.number().int().nonnegative(), + resultCount: z.number().int().min(0).max(PAGE_SIZE), + totalResults: z.number().int().nonnegative(), + }), + }), +}) + +async function prepareJamaDestination(args: ExecuteServerSelectorArgs): Promise { + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + if (!bundle.instanceUrl) throw new SelectorConnectionUnavailableError() + try { + return { + accessToken: bundle.accessToken, + instanceUrl: normalizeJamaInstanceUrl(bundle.instanceUrl), + } + } catch { + throw new SelectorConnectionUnavailableError() + } +} + +function resourceId(value: string): string { + try { + return jamaId(value, 'Resource ID') + } catch { + throw new SelectorContextUnavailableError() + } +} + +async function fetchJama( + args: ExecuteServerSelectorArgs, + destination: JamaDestination, + path: string, + query: Record = {}, + allowMissing = false +): Promise { + let response: Awaited> + try { + response = await secureFetchWithValidation(jamaUrl(destination.instanceUrl, path, query), { + profile: 'configuredEndpoint', + method: 'GET', + headers: { Authorization: `Bearer ${destination.accessToken}`, Accept: 'application/json' }, + maxRedirects: 0, + maxResponseBytes: 2 * 1024 * 1024, + timeout: 15_000, + signal: args.signal, + logUrlValidationDetails: false, + }) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + if (allowMissing && response.status === 404) return null + throw selectorProviderStatusError(response.status) + } + try { + return await response.json() + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } +} + +function option(resource: z.infer): SafeSelectorOption { + return { + id: String(resource.id), + label: truncate( + resource.display?.trim() || + resource.name?.trim() || + resource.fields?.name?.trim() || + String(resource.id), + 200 + ), + } +} + +async function listJamaResources( + args: ExecuteServerSelectorArgs, + destination: JamaDestination, + path: string +) { + const request = requireListRequest(args.selectorKey, args.request) + const cursor = request.cursor ?? '0' + const startAt = Number(cursor) + if (!/^\d+$/.test(cursor) || !Number.isSafeInteger(startAt) || startAt < 0) { + throw new SelectorContextUnavailableError() + } + const parsed = pageSchema.safeParse( + await fetchJama(args, destination, path, { + startAt, + maxResults: PAGE_SIZE, + }) + ) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + const { + data, + meta: { pageInfo }, + } = parsed.data + if (pageInfo.startIndex !== startAt || pageInfo.resultCount !== data.length) { + throw new SelectorOptionsUnavailableError() + } + const next = startAt + pageInfo.resultCount + return listSelectorResult( + data.map(option), + data.length > 0 && Number.isSafeInteger(next) && next < pageInfo.totalResults + ? String(next) + : undefined + ) +} + +export const jamaSelectorAttachments = { + 'jama.projects': definePreparedSelectorAttachment({ + credential, + destination: { kind: 'credential-bound', prepare: prepareJamaDestination }, + execute: async (args, destination) => { + if (args.request.kind === 'detail') { + const body = await fetchJama( + args, + destination, + `/projects/${resourceId(args.request.id)}`, + {}, + true + ) + if (body === null) return detailSelectorResult(null) + const parsed = resourceSchema.safeParse(toRecord(body).data) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return detailSelectorResult(option(parsed.data)) + } + return listJamaResources(args, destination, '/projects') + }, + }), + 'jama.itemTypes': definePreparedSelectorAttachment({ + credential, + destination: { kind: 'credential-bound', prepare: prepareJamaDestination }, + execute: async (args, destination) => + listJamaResources( + args, + destination, + args.context.projectId + ? `/projects/${resourceId(args.context.projectId)}/itemtypes` + : '/itemtypes' + ), + }), +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts index 96b55b1a844..ebc35148dfd 100644 --- a/apps/sim/lib/selectors/server/registry.ts +++ b/apps/sim/lib/selectors/server/registry.ts @@ -11,12 +11,15 @@ import { cloudWatchSelectorAttachments } from '@/lib/selectors/server/providers/ import { codaSelectorAttachments } from '@/lib/selectors/server/providers/coda' import { confluenceSelectorAttachments } from '@/lib/selectors/server/providers/confluence' import { fivetranSelectorAttachments } from '@/lib/selectors/server/providers/fivetran' +import { flowSelectorAttachments } from '@/lib/selectors/server/providers/flow' +import { gaugeSelectorAttachments } from '@/lib/selectors/server/providers/gauge' 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 { helpScoutSelectorAttachments } from '@/lib/selectors/server/providers/helpscout' import { hubspotSelectorAttachments } from '@/lib/selectors/server/providers/hubspot' import { imapSelectorAttachments } from '@/lib/selectors/server/providers/imap' +import { jamaSelectorAttachments } from '@/lib/selectors/server/providers/jama' import { jiraSelectorAttachments } from '@/lib/selectors/server/providers/jira' import { jsmSelectorAttachments } from '@/lib/selectors/server/providers/jsm' import { linearSelectorAttachments } from '@/lib/selectors/server/providers/linear' @@ -40,6 +43,8 @@ import { zoomSelectorAttachments } from '@/lib/selectors/server/providers/zoom' import type { ServerSelectorAttachment } from '@/lib/selectors/server/types' export const serverSelectorRegistry = { + ...flowSelectorAttachments, + ...jamaSelectorAttachments, ...internalSelectorAttachments, ...mcpSelectorAttachments, ...airtableSelectorAttachments, @@ -53,6 +58,7 @@ export const serverSelectorRegistry = { ...codaSelectorAttachments, ...confluenceSelectorAttachments, ...fivetranSelectorAttachments, + ...gaugeSelectorAttachments, ...googleSelectorAttachments, ...githubSelectorAttachments, ...harmonicSelectorAttachments, diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts index de592ca66d4..bc07decc58c 100644 --- a/apps/sim/lib/selectors/types.ts +++ b/apps/sim/lib/selectors/types.ts @@ -5,6 +5,7 @@ export const selectorContextKeys = [ 'serviceTokenId', 'serviceToken', 'organization', + 'category', 'branch', 'mcpServerId', 'domain', diff --git a/apps/sim/lib/sim-search/connectors.ts b/apps/sim/lib/sim-search/connectors.ts index 8bff18561db..f21972c8c63 100644 --- a/apps/sim/lib/sim-search/connectors.ts +++ b/apps/sim/lib/sim-search/connectors.ts @@ -115,9 +115,13 @@ export function personalSetupFields(meta: ConnectorMeta): ConnectorConfigField[] ) } -/** The name a connector shows, from its registry entry. */ +/** The registered display name for a connector or native Search service. */ export function connectorDisplayName(connectorType: string): string { - return CONNECTOR_META_REGISTRY[connectorType]?.name ?? connectorType + return ( + CONNECTOR_META_REGISTRY[connectorType]?.name ?? + getServiceConfigByServiceId(connectorType)?.name ?? + connectorType + ) } export interface OAuthServiceAvailabilityContext { diff --git a/apps/sim/lib/sim-search/live/README.md b/apps/sim/lib/sim-search/live/README.md index 5aecb3ee723..b03f76fd6d0 100644 --- a/apps/sim/lib/sim-search/live/README.md +++ b/apps/sim/lib/sim-search/live/README.md @@ -5,7 +5,7 @@ This describes the live enterprise-search path. Credential Groups and ordinary k ## Admin and member surfaces - **Sources** controls integration availability and account mode per integration. -- **Member accounts** has no resource configuration. A member connects their own account under Integrations, and searches content that account can access through the provider API. Old member resource filters are ignored and cleared when settings are saved. +- **Member accounts** has no resource configuration. A member connects their own account under Integrations, and searches content that account can access through the provider API. A member can add several accounts of one provider, such as Google Drive in two tenants; each is searched and cited separately, and every search counts them toward its account limit. Old member resource filters are ignored and cleared when settings are saved. - **Service account** selects an admin-managed source. Its connection owns the resource settings; Sources does not duplicate them. GitHub uses one GitHub App source per repository instead of a single selected source. A member still connects their own account, except for GitLab. Search returns the intersection of the member's provider permissions and the source's current resource boundary. - GitLab is always service-account mode. Slack and Jira currently support member mode only. - Removing a source integration prevents subsequent account resolution, searches, and reads. Paused, archived, deleted, wrong-organization, wrong-provider, and unapproved service sources cannot authorize search. @@ -198,3 +198,11 @@ For end-to-end verification, use authorized fixture accounts on localhost: add t GitHub issue/PR reads include ordinary comments, submitted review decisions and inline review comments with author, date, source link and diff context. Issue-search previews prefer the matching comment fragment. `in:comments` does not guarantee discovery of inline review text. Drive comments and nested replies enrich document reads; Drive file search does not index those discussions. Both adapters report pagination, permission or text caps as incomplete at the start of the read. Fireflies and Granola use `eventStartAt` for generic date filters; modification filters require independent provider modification metadata. Granola semantic answers are never substituted for source notes: only identifiable cited meetings that can be read become documents. Notion MCP searches content rather than REST titles; plan-dependent tool access, ignored filters and bounded results must remain visible to callers. + +## Personal saved credentials + +Jama Connect and Flow Engineering use the existing encrypted saved-credential framework in member mode. Search lists only credentials created by the acting user in the current scope, then compares a fresh provider identity with the member's verified Sim email. Organization setup, reconnect, and disconnect use the personal Search credential operation; ordinary service-account sharing does not grant another person Search access. + +Jama exchanges personal client credentials against the configured instance, preserving deployment context paths. Flow uses a saved API host and workspace identifier with its API key. Neither a query nor a returned item may change those destinations. Document references include a connection binding derived from the provider host, tenant, and user identity; changing a saved connection cannot silently reuse an older item ID in another instance. + +Both adapters use the existing bounded native HTTP client. Jama returns one page of up to 50 abstract items. Flow searches up to six projects and reads the current entities within each project, with explicit incomplete-result reporting when provider limits apply. Neither adapter creates an indexing connector or background crawl. diff --git a/apps/sim/lib/sim-search/live/account-session.ts b/apps/sim/lib/sim-search/live/account-session.ts index ba3c256e6a0..40c7622f975 100644 --- a/apps/sim/lib/sim-search/live/account-session.ts +++ b/apps/sim/lib/sim-search/live/account-session.ts @@ -37,7 +37,7 @@ import { readZoomMcp, searchZoomMcp } from '@/lib/sim-search/live/zoom-mcp' type Reference = Pick< NativeDocument, - 'id' | 'container' | 'kind' | 'revision' | 'threadId' | 'accessMetadata' + 'id' | 'container' | 'kind' | 'revision' | 'threadId' | 'accessMetadata' | 'connectionBinding' > /** One member account's provider clients and the source boundary that bounds its results. */ @@ -88,6 +88,9 @@ export async function openLiveAccountSession( const { owner, userId, resolved, signal } = input const { account } = resolved const provider = account.provider + const connectionBinding = 'connectionBinding' in resolved ? resolved.connectionBinding : undefined + const bind = (document: NativeDocument): NativeDocument => + connectionBinding ? { ...document, connectionBinding } : document if (!(await isSearchProviderEnabled(provider, resourceScopeFromOwner(owner)))) throw new NativeSearchError( 'unavailable', @@ -95,11 +98,15 @@ export async function openLiveAccountSession( ) const origin = 'origin' in resolved ? resolved.origin : LIVE_SEARCH_PROVIDER_CATALOG[provider].origin + if (!origin) + throw new NativeSearchError('reconnect', 'Reconnect this provider to restore its API host.') const client = account.type === 'managed_mcp' ? null : createNativeClient({ origin, + credentialHeaders: + 'credentialHeaders' in resolved ? resolved.credentialHeaders : undefined, accessToken: resolved.accessToken, signal, pool: input.pool, @@ -213,28 +220,33 @@ export async function openLiveAccountSession( if (scoped === null) return { documents: [] } if (admin) return admin.search(scoped) return searchWithinPolicy(provider, client, scoped, (native) => - client ? searchNativeProvider(provider, client, native) : searchMcp(native) + client ? searchNativeProvider(provider, client, native, origin) : searchMcp(native) ) } /** Providers see the session's cancellation, whatever its reason, on every request. */ const request = search.signal ? search : { ...search, signal } - return client && requiresEveryTerm(provider) + const page = await (client && requiresEveryTerm(provider) ? searchWithAnyTermFallback(searchOnce, request, signal) - : searchOnce(request) + : searchOnce(request)) + return connectionBinding ? { ...page, documents: page.documents.map(bind) } : page }, async verify(document) { + if (connectionBinding && document.connectionBinding !== connectionBinding) return false if (!(await boundary.verify(document))) return false return !admin || admin.verify(document, document.accessMetadata) }, - read(reference, filters) { + async read(reference, filters) { if (admin) return admin.read(reference) if (client) - return readNativeProvider(provider, client, reference, { - policy: boundary.policy, - filters, - signal, - verify: boundary.verify, - }) + return bind( + await readNativeProvider(provider, client, reference, { + policy: boundary.policy, + origin, + filters, + signal, + verify: boundary.verify, + }) + ) return readMcp(reference) }, async previewText(document, previewSignal) { diff --git a/apps/sim/lib/sim-search/live/accounts.test.ts b/apps/sim/lib/sim-search/live/accounts.test.ts index 249d3c7d698..e0fd50ce09b 100644 --- a/apps/sim/lib/sim-search/live/accounts.test.ts +++ b/apps/sim/lib/sim-search/live/accounts.test.ts @@ -55,7 +55,7 @@ const owner = { workspaceId: 'workspace' } const account = { id: 'mine', providerId: 'google-drive', displayName: 'My Drive', type: 'oauth' } function metadata(revokedAt: Date | null = null) { mockListOrganizationSearchApprovals.mockResolvedValue(new Map([['google_drive', true]])) - queueTableRows(schemaMock.credential, []) // Coda discovery + queueTableRows(schemaMock.credential, []) queueTableRows(schemaMock.credential, [ { id: 'mine', scope: 'drive.readonly', grantedScopes: null, revokedAt }, ]) @@ -136,18 +136,19 @@ describe('live account discovery boundaries', () => { { id: 'enrolled', providerId: 'jira', displayName: 'Enrolled Jira' }, ]) const legacy = { id: 'legacy', providerId: 'jira', displayName: 'Legacy Jira' } + mocks.personal.mockResolvedValue([legacy]) const metadata = ['enrolled', 'legacy'].map((id) => ({ id, scope: 'read:jira-work', grantedScopes: null, revokedAt: null, })) - queueTableRows(schemaMock.credential, [legacy]) - queueTableRows(schemaMock.credential, metadata) + queueTableRows(schemaMock.credential, []) queueTableRows(schemaMock.credential, metadata) expect( (await listLiveAccounts({ organizationId: 'org' }, 'reader')).map((row) => row.id) ).toEqual(['enrolled']) + expect(mocks.personal).not.toHaveBeenCalled() }) it('does not resolve another account even when the caller knows its ID', async () => { metadata() diff --git a/apps/sim/lib/sim-search/live/accounts.ts b/apps/sim/lib/sim-search/live/accounts.ts index db08911c759..742700ff974 100644 --- a/apps/sim/lib/sim-search/live/accounts.ts +++ b/apps/sim/lib/sim-search/live/accounts.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { account, credential, user } from '@sim/db/schema' import { and, eq, inArray, isNull } from 'drizzle-orm' import { liveSearchProviderSchema } from '@/lib/api/contracts/mothership-assistant-tools' +import { fingerprint } from '@/lib/api/cursor-binding' import { type ResourceOwner, type ResourceScope, @@ -9,10 +10,12 @@ import { resourceScopeFromOwner, } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limits' import { filterWorkspaceAccountCredentials } from '@/lib/credentials/application/workspace-account-visibility' import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' import { getOwnOrganizationManagedOAuthCredentials } from '@/lib/credentials/organization-managed' import { getPersonalOAuthCredentials } from '@/lib/credentials/personal' +import { flowHeaders, normalizeFlowOrigin } from '@/lib/flow/client' import { resolveKnowledgeWorkspaceContext } from '@/lib/knowledge/application/contexts' import { listOrganizationSearchApprovals } from '@/lib/knowledge/search/integration-policy' import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' @@ -23,7 +26,9 @@ import { import { createNativeClient, NativeSearchError, object, string } from '@/lib/sim-search/live/http' import { listManagedMcpSearchAccounts } from '@/lib/sim-search/live/mcp-accounts' import { + LIVE_SEARCH_PROVIDER_CATALOG, liveSearchProviderForCredential, + SAVED_MEMBER_SEARCH_PROVIDER_IDS, supportsLiveSearchMode, } from '@/lib/sim-search/live/provider-catalog' import type { LiveAccount } from '@/lib/sim-search/live/types' @@ -41,7 +46,8 @@ async function listOAuthRows(scope: ResourceScope, userId: string) { return managed.map((row) => ({ ...row, type: 'managed_oauth' as const })) } -async function listCodaTokenRows(scope: ResourceScope, userId: string) { +/** Lists only credentials created by the acting person in the authorized resource scope. */ +export async function listSavedSearchCredentials(scope: ResourceScope, userId: string) { const rows = await db .select({ id: credential.id, @@ -53,14 +59,15 @@ async function listCodaTokenRows(scope: ResourceScope, userId: string) { and( resourceScopeCondition(credential, scope), eq(credential.type, 'service_account'), - eq(credential.providerId, 'coda-service-account'), + inArray(credential.providerId, SAVED_MEMBER_SEARCH_PROVIDER_IDS), eq(credential.createdBy, userId), isNull(credential.revokedAt) ) ) + .limit(ORGANIZATION_VIEWER_ACCOUNT_LIMIT) return rows.map((row) => ({ ...row, - providerId: 'coda-service-account', + providerId: row.providerId ?? '', type: 'service_account' as const, })) } @@ -74,9 +81,9 @@ export async function listLiveAccounts( /** A workspace's organization is known only after its context loads; an organization's is not. */ const loadApprovals = (organizationId?: string | null) => organizationId ? listOrganizationSearchApprovals(organizationId) : null - const [oauth, coda, workspaceContext, organizationApprovals] = await Promise.all([ + const [oauth, saved, workspaceContext, organizationApprovals] = await Promise.all([ listOAuthRows(scope, userId), - listCodaTokenRows(scope, userId), + listSavedSearchCredentials(scope, userId), scope.kind === 'workspace' ? resolveKnowledgeWorkspaceContext({ workspaceId: scope.workspaceId }) : undefined, @@ -89,7 +96,7 @@ export async function listLiveAccounts( ? liveSearchProviderSchema.options.filter((provider) => approvals.get(provider) !== true) : [] ) - const candidates = [...oauth, ...coda].flatMap((row) => { + const candidates = [...oauth, ...saved].flatMap((row) => { const provider = liveSearchProviderForCredential(row.providerId) return provider && supportsLiveSearchMode(provider, 'member') && !denied.has(provider) ? [ @@ -192,6 +199,22 @@ export async function resolveListedLiveAccount( { signal } ) if (!token) throw new NativeSearchError('reconnect', 'Reconnect your personal account.') + const origin = + current.provider === 'jama' + ? token.instanceUrl + : current.provider === 'flow' + ? token.domain && normalizeFlowOrigin(token.domain) + : LIVE_SEARCH_PROVIDER_CATALOG[current.provider].origin + if (!origin) + throw new NativeSearchError('reconnect', 'Reconnect this account to restore its API host.') + const credentialHeaders = + current.provider === 'flow' + ? flowHeaders({ + accessToken: token.accessToken, + providerWorkspaceId: token.providerWorkspaceId ?? '', + }) + : undefined + let connectionBinding: string | undefined if (current.type === 'service_account') { const [person] = await db .select({ email: user.email, verified: user.emailVerified }) @@ -199,21 +222,53 @@ export async function resolveListedLiveAccount( .where(eq(user.id, userId)) .limit(1) const api = createNativeClient({ - origin: 'https://coda.io', + origin, + credentialHeaders, accessToken: token.accessToken, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000), }) - const identity = object(await api.json('/apis/v1/whoami')) + const response = object( + await api.json( + current.provider === 'jama' + ? '/rest/v1/users/current' + : current.provider === 'flow' + ? '/users/me' + : '/apis/v1/whoami' + ) + ) + const identity = current.provider === 'jama' ? object(response.data) : response + if (current.provider === 'jama' || current.provider === 'flow') { + const subject = string(identity.id) + if (!subject) + throw new NativeSearchError('reconnect', 'The provider did not identify this account.') + connectionBinding = fingerprint( + JSON.stringify([current.provider, origin, token.providerWorkspaceId ?? '', subject]) + ) + } + const identityEmail = current.provider === 'coda' ? identity.loginId : identity.email + const inactive = + current.provider === 'jama' + ? identity.active !== true + : current.provider === 'flow' + ? identity.deactivated_at !== null || identity.is_bot === true + : false if ( !person?.verified || - string(identity.loginId).trim().toLowerCase() !== person.email.trim().toLowerCase() + inactive || + string(identityEmail).trim().toLowerCase() !== person.email.trim().toLowerCase() ) throw new NativeSearchError( 'reconnect', - 'Use a personal Coda token belonging to your verified Sim email. Shared service tokens cannot authorize personal search.' + 'Use credentials for your own provider account with the same email as your verified Sim account.' ) } - return { account: current, accessToken: token.accessToken } + return { + account: current, + accessToken: token.accessToken, + origin, + credentialHeaders, + connectionBinding, + } } diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts index 309c92ddb54..596cbe372fa 100644 --- a/apps/sim/lib/sim-search/live/application.ts +++ b/apps/sim/lib/sim-search/live/application.ts @@ -151,6 +151,7 @@ const referenceSchema = z container: z.string().max(500).optional(), kind: z.string().max(200).optional(), revision: z.string().max(200).optional(), + connectionBinding: z.string().min(1).max(128).optional(), threadId: z .string() .regex(/^\d+\.\d+$/) @@ -245,6 +246,7 @@ function candidateFor( ...(document.container ? { container: document.container } : {}), ...(document.kind ? { kind: document.kind } : {}), ...(document.revision ? { revision: document.revision } : {}), + ...(document.connectionBinding ? { connectionBinding: document.connectionBinding } : {}), ...(document.threadId ? { threadId: document.threadId } : {}), }), } @@ -970,7 +972,17 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({ }) return { query: input.query, - results: ranked.slice(0, input.topK).map(({ result }) => result), + results: ranked.slice(0, input.topK).map(({ result, queries: matched }) => { + const queryIndexes = [ + ...new Set( + matched.flatMap((index) => { + const queryIndex = searched[index]?.status.queryIndex + return queryIndex === undefined ? [] : [queryIndex] + }) + ), + ].sort((a, b) => a - b) + return queryIndexes.length ? { ...result, queryIndexes } : result + }), retrieval: { status: retrievalStatus, timedOutLegs: [], diff --git a/apps/sim/lib/sim-search/live/flow.test.ts b/apps/sim/lib/sim-search/live/flow.test.ts new file mode 100644 index 00000000000..be6367e3aa6 --- /dev/null +++ b/apps/sim/lib/sim-search/live/flow.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { readFlow, searchFlow } from '@/lib/sim-search/live/flow' +import type { NativeClient, NativeSearchInput } from '@/lib/sim-search/live/types' + +const ORIGIN = 'https://backend.branch.flowengineering.com' +const PROJECT_ID = 'c9fb5f5c-5b3b-44b7-b978-37ead6a707cf' +const ENTITY_ID = 'b2c74c54-a8cb-4b4a-96d9-5a386a7a5f25' +const reference = { id: ENTITY_ID, container: PROJECT_ID } +const entity = { + id: ENTITY_ID, + type: 'requirement', + values: { name: 'Operating temperature', description: '

Operate below 85 C.

' }, + formulas: {}, + formulaTemplates: {}, + links: { inbound: [], outbound: [], crosslinks: [], inboundOrder: {} }, + updatedAt: '2026-10-01T12:00:00Z', +} +const input = { + query: 'temperature', + limit: 10, + scopes: [], + native: { provider: 'flow', query: 'temperature', project: PROJECT_ID }, +} satisfies NativeSearchInput + +function client(json: NativeClient['json']): NativeClient { + return { + json, + text: async () => { + throw new Error('Unexpected text request') + }, + bytes: async () => { + throw new Error('Unexpected binary request') + }, + } +} + +/** Failure modes: stale hits bypassing current reads, missing project scope, malformed entity states, and wrong entity identities. */ +describe('Flow live entity boundary', () => { + it.each([ + { location: 'search project', id: '.' }, + { location: 'search project', id: '..' }, + { location: 'read project', id: '.' }, + { location: 'read project', id: '..' }, + { location: 'read entity', id: '.' }, + { location: 'read entity', id: '..' }, + ])( + 'rejects a dot segment in $location ($id) before requesting a different endpoint', + async ({ location, id }) => { + const requests: string[] = [] + const api = client(async (path) => { + requests.push(path) + return [] + }) + await expect( + location === 'search project' + ? searchFlow( + api, + { ...input, native: { ...input.native, provider: 'flow', project: id } }, + ORIGIN + ) + : readFlow( + api, + { ...reference, ...(location === 'read project' ? { container: id } : { id }) }, + ORIGIN + ) + ).rejects.toThrow('path traversal') + expect(requests).toEqual([]) + } + ) + + it('reads current project-bound content rather than returning stale search previews', async () => { + const api = client(async (path) => { + if (path === `/project/${PROJECT_ID}/entities/search`) { + return [ + { + entity_id: ENTITY_ID, + name: 'Old title', + human_id: 'REQ-1', + entity_type: 'requirement', + legacy_human_id: null, + }, + ] + } + if (path === `/project/${PROJECT_ID}/entities/${ENTITY_ID}`) return [entity] + throw new Error('Unknown Flow endpoint') + }) + const page = await searchFlow(api, input, ORIGIN) + expect(page.documents).toHaveLength(1) + expect(page.documents[0]?.title).toBe('REQ-1 · Operating temperature') + expect(page.documents[0]?.content).toContain('Operate below 85 C.') + expect(page.documents[0]?.modifiedAt).toBe(entity.updatedAt) + expect(page.documents[0]?.url).toBe(`${ORIGIN}/project/${PROJECT_ID}/entities/${ENTITY_ID}`) + const document = await readFlow(api, reference, ORIGIN) + expect(document.title).toBe('Operating temperature') + }) + + it.each([ + { name: 'null', value: null }, + { name: 'array', value: [] }, + { name: 'missing', value: undefined }, + ])( + 'rejects an entity with malformed fields $name instead of an empty document', + async ({ value: values }) => { + await expect( + readFlow( + client(async () => [{ ...entity, values }]), + reference, + ORIGIN + ) + ).rejects.toThrow('no longer available') + } + ) + + it.each([ + ['revoked access', []], + ['deleted entity', [{ ...entity, deleted: true }]], + ['changed identity', [{ ...entity, id: 'another-item' }]], + ])('does not return an old reference after %s', async (_reason, rows) => { + await expect( + readFlow( + client(async () => rows), + reference, + ORIGIN + ) + ).rejects.toThrow('no longer available') + }) + + it('marks current access omissions as partial instead of returning the indexed hit', async () => { + const api = client(async (path) => { + if (path.endsWith('/entities/search')) { + return [ + { + entity_id: ENTITY_ID, + name: 'Stale sensitive title', + human_id: 'REQ-1', + entity_type: 'requirement', + legacy_human_id: null, + }, + ] + } + return [] + }) + expect(await searchFlow(api, input, ORIGIN)).toMatchObject({ documents: [], partial: true }) + }) + + it('rejects references without their originating project', async () => { + await expect( + readFlow( + client(async () => [entity]), + { id: ENTITY_ID }, + ORIGIN + ) + ).rejects.toThrow('Invalid Flow item reference') + }) +}) diff --git a/apps/sim/lib/sim-search/live/flow.ts b/apps/sim/lib/sim-search/live/flow.ts new file mode 100644 index 00000000000..bbafcf6f06a --- /dev/null +++ b/apps/sim/lib/sim-search/live/flow.ts @@ -0,0 +1,158 @@ +import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { nativeText } from '@/lib/sim-search/live/dates' +import { array, NativeSearchError, object, string } from '@/lib/sim-search/live/http' +import { collectNativePages } from '@/lib/sim-search/live/pages' +import type { + NativeClient, + NativeDocument, + NativePage, + NativeSearchInput, +} from '@/lib/sim-search/live/types' +import { htmlToPlainText } from '@/connectors/utils' +import { safeUrlPathSegment } from '@/tools/url-path' + +const MAX_PROJECTS = 6 +const MAX_ITEM_TEXT = 120_000 + +function itemDocument( + row: Record, + origin: string, + project: string, + humanId?: string +): NativeDocument | undefined { + const id = string(row.id) + if (!id || row.deleted === true || !isRecordLike(row.values)) return undefined + const values = object(row.values) + const valueEntries = Object.entries(values) + const fields = valueEntries.slice(0, 100).flatMap(([key, value]) => { + const text = + typeof value === 'string' + ? htmlToPlainText(value) + : typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : '' + return text ? [`${key}: ${text}`] : [] + }) + const formulaEntries = Object.entries(object(row.formulas)) + const formulas = formulaEntries + .slice(0, 100) + .flatMap(([key, value]) => (typeof value === 'string' ? [`${key} formula: ${value}`] : [])) + return { + id, + container: project, + kind: string(row.type) || 'item', + title: [humanId, string(values.name) || `Item ${id}`].filter(Boolean).join(' · '), + url: new URL( + `/project/${safeUrlPathSegment(project, 'projectId')}/entities/${safeUrlPathSegment(id, 'entityId')}`, + origin + ).toString(), + content: truncate( + [ + ...fields, + ...formulas, + ...(valueEntries.length > 100 || formulaEntries.length > 100 + ? ['[Item fields truncated]'] + : []), + ].join('\n\n'), + MAX_ITEM_TEXT, + '\n[Item text truncated]' + ), + modifiedAt: string(row.updatedAt) || undefined, + } +} + +async function searchProject( + client: NativeClient, + input: NativeSearchInput, + origin: string, + project: string +): Promise { + const limit = Math.min(input.limit, 100) + const projectPath = safeUrlPathSegment(project, 'projectId') + const response = await client.json(`/project/${projectPath}/entities/search`, { + query: { + query: nativeText(input), + branch_id: 'master', + limit: String(limit), + }, + }) + if (!Array.isArray(response)) + throw new NativeSearchError('unavailable', 'Flow returned an unsupported search response.') + const hits = array(response).slice(0, limit) + const ids = [...new Set(hits.map((row) => string(row.entity_id)).filter(Boolean))] + if (!ids.length) return { documents: [], partial: response.length > 0 } + const details = await client.json( + `/project/${projectPath}/entities/${ids.map((id) => safeUrlPathSegment(id, 'entityId')).join(',')}`, + { query: { branch_id: 'master', include_metadata: 'true' } } + ) + if (!Array.isArray(details)) + throw new NativeSearchError('unavailable', 'Flow returned an unsupported item response.') + const byId = new Map(array(details).map((row) => [string(row.id), row])) + const documents = hits.flatMap((hit) => { + const row = byId.get(string(hit.entity_id)) + const document = row && itemDocument(row, origin, project, string(hit.human_id)) + return document ? [document] : [] + }) + return { + documents, + hasMore: response.length >= limit, + partial: response.length >= limit || documents.length !== hits.length, + } +} + +/** Searches a bounded set of projects, then reads every match with the same member credential. */ +export async function searchFlow( + client: NativeClient, + input: NativeSearchInput, + origin?: string +): Promise { + if (!origin) throw new NativeSearchError('reconnect', 'Reconnect Flow to restore its API URL.') + const query = nativeText(input) + if (!query || query.length > 1024) + throw new NativeSearchError( + 'unavailable', + 'Enter Flow search text between 1 and 1024 characters.' + ) + if (input.native?.cursor) + throw new NativeSearchError( + 'unavailable', + 'Flow search does not provide pagination. Narrow the query or project.' + ) + let projects: string[] + if (input.native?.project) projects = [input.native.project] + else { + const response = await client.json('/project', { query: { limit: String(MAX_PROJECTS + 1) } }) + if (!Array.isArray(response)) + throw new NativeSearchError('unavailable', 'Flow returned an unsupported project response.') + projects = array(response) + .map((row) => string(row.id)) + .filter(Boolean) + } + const result = await collectNativePages( + projects.slice(0, MAX_PROJECTS).map((project) => searchProject(client, input, origin, project)), + 'Flow searches at most six accessible projects and 100 matches per project. Search has no continuation; narrow the query or project for more results. Citations link to API resources. Reads include current field values and formulas, not discussions or files.' + ) + return { ...result, partial: result.partial || projects.length > MAX_PROJECTS } +} + +/** Fetches current entity access; deleted or omitted entities are never returned. */ +export async function readFlow( + client: NativeClient, + reference: Pick, + origin?: string +): Promise { + if (!origin || !reference.id || reference.id.length > 256 || !reference.container) + throw new NativeSearchError('unavailable', 'Invalid Flow item reference.') + const response = await client.json( + `/project/${safeUrlPathSegment(reference.container, 'projectId')}/entities/${safeUrlPathSegment(reference.id, 'entityId')}`, + { query: { branch_id: 'master', include_metadata: 'true' } } + ) + if (!Array.isArray(response)) + throw new NativeSearchError('unavailable', 'Flow returned an unsupported item response.') + const row = array(response).find((item) => string(item.id) === reference.id) + const document = row && itemDocument(row, origin, reference.container) + if (!document) + throw new NativeSearchError('unavailable', 'This Flow item is no longer available.') + return document +} diff --git a/apps/sim/lib/sim-search/live/http.ts b/apps/sim/lib/sim-search/live/http.ts index eb2d889a444..0d878690a1e 100644 --- a/apps/sim/lib/sim-search/live/http.ts +++ b/apps/sim/lib/sim-search/live/http.ts @@ -34,6 +34,8 @@ export const NATIVE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024 export function createNativeClient(input: { origin: string accessToken: string + /** Credential-bound provider headers; never taken from search arguments. */ + credentialHeaders?: Readonly> signal: AbortSignal /** Reuses connections across this client's requests; the caller owns its lifetime. */ pool?: PinnedConnectionPool @@ -73,7 +75,7 @@ export function createNativeClient(input: { } if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) throw new Error('Invalid provider path') - url.pathname = path + url.pathname = `${url.pathname.replace(/\/$/, '')}${path}` for (const [key, value] of Object.entries(options?.query ?? {})) { for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, item) @@ -81,7 +83,7 @@ export function createNativeClient(input: { const response = await secureFetchWithValidation(url.toString(), { profile: 'configuredEndpoint', headers: { - Authorization: `Bearer ${input.accessToken}`, + ...(input.credentialHeaders ?? { Authorization: `Bearer ${input.accessToken}` }), Accept: url.hostname === 'api.github.com' ? 'application/vnd.github.text-match+json' diff --git a/apps/sim/lib/sim-search/live/jama.test.ts b/apps/sim/lib/sim-search/live/jama.test.ts new file mode 100644 index 00000000000..7c1c3cc275d --- /dev/null +++ b/apps/sim/lib/sim-search/live/jama.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { readJama } from '@/lib/sim-search/live/jama' +import type { NativeClient } from '@/lib/sim-search/live/types' + +/** A malformed provider item must not become a successful blank document under a valid reference. */ +describe('Jama live item boundary', () => { + it.each([ + { name: 'null', value: null }, + { name: 'array', value: [] }, + { name: 'missing', value: undefined }, + ])('rejects malformed item fields $name', async ({ value: fields }) => { + const api: NativeClient = { + json: async () => ({ + data: { id: 101, project: 7, documentKey: 'REQ-101', fields }, + }), + text: async () => { + throw new Error('Unexpected text request') + }, + bytes: async () => { + throw new Error('Unexpected binary request') + }, + } + await expect( + readJama(api, { id: '101', container: '7' }, 'https://example.jamacloud.com') + ).rejects.toThrow('no longer available') + }) +}) diff --git a/apps/sim/lib/sim-search/live/jama.ts b/apps/sim/lib/sim-search/live/jama.ts new file mode 100644 index 00000000000..0219f9b00f7 --- /dev/null +++ b/apps/sim/lib/sim-search/live/jama.ts @@ -0,0 +1,135 @@ +import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { nativeText } from '@/lib/sim-search/live/dates' +import { array, NativeSearchError, object, string } from '@/lib/sim-search/live/http' +import type { + NativeClient, + NativeDocument, + NativePage, + NativeSearchInput, +} from '@/lib/sim-search/live/types' +import { htmlToPlainText } from '@/connectors/utils' + +const NUMERIC_ID = /^[1-9]\d*$/ +const MAX_ITEM_TEXT = 120_000 + +function itemDocument(row: Record, origin: string): NativeDocument | undefined { + const id = string(row.id) + const project = string(row.project) + if (!NUMERIC_ID.test(id) || !NUMERIC_ID.test(project) || !isRecordLike(row.fields)) + return undefined + const fields = object(row.fields) + const entries = Object.entries(fields) + const content = entries + .slice(0, 100) + .flatMap(([name, value]) => { + const text = + typeof value === 'string' + ? htmlToPlainText(value) + : typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : '' + return text ? [`${name}: ${text}`] : [] + }) + .join('\n\n') + return { + id, + container: project, + kind: 'item', + title: + [string(row.documentKey), string(fields.name)].filter(Boolean).join(' · ') || `Item ${id}`, + url: `${origin}/rest/v1/abstractitems/${id}`, + content: truncate( + content + (entries.length > 100 ? '\n[Item fields truncated]' : ''), + MAX_ITEM_TEXT, + '\n[Item text truncated]' + ), + modifiedAt: string(row.modifiedDate) || undefined, + } +} + +/** Retrieves one bounded page using the connected person's current Jama permissions. */ +export async function searchJama( + client: NativeClient, + input: NativeSearchInput, + origin?: string +): Promise { + if (!origin) + throw new NativeSearchError('reconnect', 'Reconnect Jama Connect to restore its instance URL.') + const query = nativeText(input) + if (!query || query.length > 1024) + throw new NativeSearchError( + 'unavailable', + 'Enter Jama search text between 1 and 1024 characters.' + ) + const project = input.native?.project + if (project && !NUMERIC_ID.test(project)) + throw new NativeSearchError('unavailable', 'Jama project scope requires a numeric project ID.') + const cursor = input.native?.cursor ?? '0' + if (!/^\d{1,9}$/.test(cursor)) + throw new NativeSearchError('unavailable', 'Invalid Jama search cursor.') + const start = Number(cursor) + const limit = Math.min(input.limit, 50) + const response = object( + await client.json('/rest/v1/abstractitems', { + query: { + contains: query, + startAt: String(start), + maxResults: String(limit), + ...(project ? { project } : {}), + }, + }) + ) + const page = object(object(response.meta).pageInfo) + if ( + !Array.isArray(response.data) || + page.startIndex !== start || + typeof page.resultCount !== 'number' || + !Number.isInteger(page.resultCount) || + page.resultCount < 0 || + typeof page.totalResults !== 'number' || + !Number.isInteger(page.totalResults) || + page.totalResults < 0 + ) { + throw new NativeSearchError('unavailable', 'Jama returned an unsupported search response.') + } + const rows = array(response.data) + const documents = rows.slice(0, limit).flatMap((row) => { + const document = itemDocument(row, origin) + return document && (!project || document.container === project) ? [document] : [] + }) + const next = start + page.resultCount + const hasMore = next < page.totalResults + const completePage = rows.length <= limit && page.resultCount === rows.length + return { + documents, + hasMore, + nextCursor: hasMore && completePage && next > start ? String(next) : undefined, + partial: !completePage || documents.length !== rows.length || (hasMore && next <= start), + message: + 'Jama searches accessible item fields. Dates and sorting apply to the returned page. Citations link to the item API resource; attachments and discussions are not included.', + } +} + +/** A read goes back to Jama rather than trusting content from an earlier search. */ +export async function readJama( + client: NativeClient, + reference: Pick, + origin?: string +): Promise { + if (!origin || !NUMERIC_ID.test(reference.id)) + throw new NativeSearchError('unavailable', 'Invalid Jama item reference.') + const response = object(await client.json(`/rest/v1/abstractitems/${reference.id}`)) + const document = itemDocument(object(response.data), origin) + if ( + !document || + document.id !== reference.id || + (reference.container && document.container !== reference.container) + ) { + throw new NativeSearchError( + 'unavailable', + 'This Jama item is no longer available in the referenced project.' + ) + } + return document +} diff --git a/apps/sim/lib/sim-search/live/managed-mcp.integration.ts b/apps/sim/lib/sim-search/live/managed-mcp.integration.ts index e4b554e98c1..b649e17e0cd 100644 --- a/apps/sim/lib/sim-search/live/managed-mcp.integration.ts +++ b/apps/sim/lib/sim-search/live/managed-mcp.integration.ts @@ -21,6 +21,7 @@ import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { eq, inArray } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { workspaceKnowledgeSearchDataSchema } from '@/lib/api/contracts/mothership-assistant-tools' import { env } from '@/lib/core/config/env' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' import { createManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-service' @@ -493,6 +494,12 @@ describe('managed Search operation sessions', () => { 'Second topology', TITLE, ]) + expect(found.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ documentName: TITLE, queryIndexes: [0] }), + expect.objectContaining({ documentName: 'Second topology', queryIndexes: [1] }), + ]) + ) expect(found.retrieval.status).toBe('complete') expect( found.live?.accounts.map(({ queryIndex, status }) => ({ queryIndex, status })) @@ -576,6 +583,12 @@ describe('managed Search operation sessions', () => { 'Second topology', TITLE, ]) + expect(parallel.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ documentName: TITLE, queryIndexes: [0] }), + expect.objectContaining({ documentName: 'Second topology', queryIndexes: [1] }), + ]) + ) for (const options of [{ project: '42' }, { topK: 5 }, { startDate: '2026-09-01T00:00:00Z' }]) { expect((await browse(0, options)).live?.accounts[0]?.status).not.toBe('unavailable') @@ -633,10 +646,19 @@ describe('managed Search operation sessions', () => { kind: 'lucidchart', accountId: actor.credentialId, }, + { + provider: 'lucid', + query: 'architecture', + kind: 'lucidchart', + accountId: actor.credentialId, + }, ], }, }) expect(found.results).toHaveLength(1) + expect(workspaceKnowledgeSearchDataSchema.parse(found).results[0]).toMatchObject({ + queryIndexes: [1, 2], + }) expect(found.live?.accounts).toEqual([ expect.objectContaining({ queryIndex: 0, @@ -644,6 +666,7 @@ describe('managed Search operation sessions', () => { nextCursor: expect.any(String), }), expect.objectContaining({ queryIndex: 1, status: 'ok' }), + expect.objectContaining({ queryIndex: 2, status: 'ok' }), ]) expect(openTransports.size).toBe(0) }) diff --git a/apps/sim/lib/sim-search/live/policy-schema.ts b/apps/sim/lib/sim-search/live/policy-schema.ts index 05b7d839d5a..06e3d315e37 100644 --- a/apps/sim/lib/sim-search/live/policy-schema.ts +++ b/apps/sim/lib/sim-search/live/policy-schema.ts @@ -99,6 +99,16 @@ export const LIVE_SEARCH_SCOPE_FIELDS: Record< hint: 'Use owner/repository or paste a GitHub repository URL.', example: 'simstudioai/sim', }, + jama: { + label: 'Items', + hint: 'Member accounts search the Jama Connect items they can access.', + example: '', + }, + flow: { + label: 'Engineering items', + hint: 'Member accounts search the Flow projects and items they can access.', + example: '', + }, gitlab: { label: 'Projects', hint: 'Use project IDs or namespace/project paths. Restrict the instance below when connecting multiple GitLab hosts.', diff --git a/apps/sim/lib/sim-search/live/policy.test.ts b/apps/sim/lib/sim-search/live/policy.test.ts index 5d350fa0898..78c55471d27 100644 --- a/apps/sim/lib/sim-search/live/policy.test.ts +++ b/apps/sim/lib/sim-search/live/policy.test.ts @@ -239,4 +239,22 @@ describe('organization search scope enforcement', () => { expect(await verify({ id: 'https://coda.io:444/d/doc' })).toBe(false) expect(await verify({ id: 'https://evil.test/d/doc' })).toBe(false) }) + it.each(['jama', 'flow'] as const)( + 'applies selected and excluded project IDs to current %s documents', + async (provider) => { + const verify = createPolicyVerifier(provider, selected(['7', '8'], ['8']), client({}), '') + expect(await verify({ id: 'item', container: '7' })).toBe(true) + expect(await verify({ id: 'item', container: '8' })).toBe(false) + expect(await verify({ id: 'item', container: '9' })).toBe(false) + expect(await verify({ id: 'item' })).toBe(false) + const excluding = createPolicyVerifier( + provider, + { ...defaultLiveSearchPolicy(), excluded: ['8'] }, + client({}), + '' + ) + expect(await excluding({ id: 'item', container: '7' })).toBe(true) + expect(await excluding({ id: 'item', container: '8' })).toBe(false) + } + ) }) diff --git a/apps/sim/lib/sim-search/live/policy.ts b/apps/sim/lib/sim-search/live/policy.ts index 026a1419efa..71972a7e60f 100644 --- a/apps/sim/lib/sim-search/live/policy.ts +++ b/apps/sim/lib/sim-search/live/policy.ts @@ -173,6 +173,13 @@ export function createPolicyVerifier( } return true } + if (provider === 'jama' || provider === 'flow') { + return ( + Boolean(document.container) && + siteAllowed(origin) && + permitsResources(policy, [document.container ?? '']) + ) + } if (provider === 'gitlab') { if (!siteAllowed(origin) || !document.container) return false if (!restricted && policy.includeArchived && !policy.pathPrefixes.length) return true diff --git a/apps/sim/lib/sim-search/live/provider-catalog.ts b/apps/sim/lib/sim-search/live/provider-catalog.ts index 918def87f90..4c39c6a3251 100644 --- a/apps/sim/lib/sim-search/live/provider-catalog.ts +++ b/apps/sim/lib/sim-search/live/provider-catalog.ts @@ -1,7 +1,8 @@ interface LiveSearchProviderDefinition { - origin: string + origin: string | null credentialProviderIds: readonly string[] modes: readonly ('member' | 'service_account')[] + savedMemberCredential?: boolean } /** Browser-safe capabilities; branding and setup fields live in source-catalog. */ @@ -51,6 +52,18 @@ export const LIVE_SEARCH_PROVIDER_CATALOG = { credentialProviderIds: ['github-repositories'], modes: ['member', 'service_account'], }, + jama: { + origin: null, + credentialProviderIds: ['jama-service-account'], + modes: ['member'], + savedMemberCredential: true, + }, + flow: { + origin: 'https://backend.branch.flowengineering.com', + credentialProviderIds: ['flow-service-account'], + modes: ['member'], + savedMemberCredential: true, + }, gitlab: { origin: 'https://gitlab.com', credentialProviderIds: ['gitlab'], @@ -89,6 +102,7 @@ export const LIVE_SEARCH_PROVIDER_CATALOG = { coda: { origin: 'https://coda.io', credentialProviderIds: ['coda-service-account'], + savedMemberCredential: true, modes: ['member', 'service_account'], }, } as const satisfies Record @@ -115,3 +129,11 @@ export function supportsLiveSearchMode( ): boolean { return (LIVE_SEARCH_PROVIDER_CATALOG[provider].modes as readonly string[]).includes(mode) } + +/** Saved credentials whose owner must prove the current provider identity before every Search use. */ +export const SAVED_MEMBER_SEARCH_PROVIDER_IDS = LIVE_SEARCH_PROVIDER_IDS.flatMap((provider) => { + const definition = LIVE_SEARCH_PROVIDER_CATALOG[provider] + return 'savedMemberCredential' in definition && definition.savedMemberCredential + ? [...definition.credentialProviderIds] + : [] +}) diff --git a/apps/sim/lib/sim-search/live/providers.ts b/apps/sim/lib/sim-search/live/providers.ts index 7333304ae55..7251ba13c75 100644 --- a/apps/sim/lib/sim-search/live/providers.ts +++ b/apps/sim/lib/sim-search/live/providers.ts @@ -1,6 +1,7 @@ import { MAX_NATIVE_QUERIES_PER_ACCOUNT } from '@/lib/api/contracts/mothership-assistant-tools' import { previewConfluence, readAtlassian, searchAtlassian } from '@/lib/sim-search/live/atlassian' import { readCoda, searchCoda } from '@/lib/sim-search/live/coda' +import { readFlow, searchFlow } from '@/lib/sim-search/live/flow' import { readGitHub, searchGitHub } from '@/lib/sim-search/live/github' import { readGitLab, searchGitLab } from '@/lib/sim-search/live/gitlab' import { @@ -14,6 +15,7 @@ import { } from '@/lib/sim-search/live/google' import { readGoogleMeet, searchGoogleMeet } from '@/lib/sim-search/live/google-meet' import { NativeSearchError } from '@/lib/sim-search/live/http' +import { readJama, searchJama } from '@/lib/sim-search/live/jama' import { readLinear, searchLinear } from '@/lib/sim-search/live/linear' import { LIVE_SEARCH_PROVIDER_IDS, @@ -53,7 +55,7 @@ interface NativeProvider { * metadata. At most one request; undefined for kinds without a cheap text form. */ previewText?(client: NativeClient, document: NativeDocument): Promise - search(client: NativeClient, input: NativeSearchInput): Promise + search(client: NativeClient, input: NativeSearchInput, origin?: string): Promise read( client: NativeClient, reference: Pick, @@ -183,12 +185,12 @@ export const LIVE_SEARCH_PROVIDERS = { confluence: { guide: { syntax: - 'CQL: text ~ "term", title ~ "term" (title ~ "win*" for a prefix), type IN (page, blogpost) and space = KEY (quote keys starting with a digit), joined with AND/OR/NOT and parentheses; add ORDER BY lastmodified DESC only when recency matters more than relevance.', + 'CQL: siteSearch ~ "a few words" is Confluence relevance search and ranks pages about the topic first, so use it for topic, acronym and how-to lookups. text ~ "term" and title ~ "term" (title ~ "win*" for a prefix) match pages containing those words or their variants without that ranking, so keep them for a specific identifier or term; text ~ "\\"exact phrase\\"" matches a phrase. Combine with type IN (page, blogpost) and space = KEY (quote keys starting with a digit) using AND/OR/NOT and parentheses; add ORDER BY lastmodified DESC only when recency matters more than relevance.', scope: 'creator = currentUser(), contributor = currentUser(), mention = currentUser() and space = KEY. CQL has no project field; the separate native project field takes an Atlassian site cloud ID.', - example: 'type IN (page, blogpost) AND space = ENG AND text ~ "roadmap"', + example: 'type IN (page, blogpost) AND siteSearch ~ "deployment freeze process"', avoid: - 'starting the query with a negative clause (NOT, !=, !~ or NOT IN), which CQL rejects; lead with a positive clause such as type IN (page, blogpost).', + 'ORing several text ~ clauses for a topic, which ranks pages sharing one common word first (put the words in one siteSearch instead), and starting the query with a negative clause (NOT, !=, !~ or NOT IN), which CQL rejects; lead with a positive clause such as type IN (page, blogpost).', }, requiresEveryTerm: true, previewText: previewConfluence, @@ -210,6 +212,32 @@ export const LIVE_SEARCH_PROVIDERS = { read: (client, reference) => readGitHub(client, reference.id, reference.container, reference.kind), }, + jama: { + guide: { + syntax: + 'Literal words searched by Jama Connect across accessible items. Use a short distinctive phrase. Results are paginated with the returned cursor.', + scope: + 'project optionally takes a numeric Jama project ID. Dates filter returned item modification timestamps; results use Jama default ordering, not a global newest/oldest guarantee.', + example: 'battery safety', + avoid: + 'Boolean or field operators, invented project IDs, and expecting attachments or full discussion search. Read a result for its current item fields.', + }, + search: searchJama, + read: (client, reference, options) => readJama(client, reference, options.origin), + }, + flow: { + guide: { + syntax: + 'Nonempty plain text up to 1024 characters, searched by Flow across accessible engineering items. Results are bounded; the API provides no search cursor.', + scope: + 'project optionally takes a Flow project ID. Without project, searches at most six accessible projects. Dates and sorting apply to returned entity metadata only.', + example: 'battery safety', + avoid: + 'Boolean operators, empty browsing queries, cursor pagination, and claiming exhaustive coverage. Read a match for current field values and formulas; comments and files are not included.', + }, + search: searchFlow, + read: (client, reference, options) => readFlow(client, reference, options.origin), + }, gitlab: { guide: { syntax: @@ -302,7 +330,8 @@ export const LIVE_SEARCH_PROVIDERS = { export function searchNativeProvider( provider: LiveSearchProviderId, client: NativeClient, - input: NativeSearchInput + input: NativeSearchInput, + origin?: string ): Promise { const adapter = LIVE_SEARCH_PROVIDERS[provider] if (!('search' in adapter)) @@ -310,7 +339,7 @@ export function searchNativeProvider( 'unavailable', `${provider} requires a connected member MCP account` ) - return adapter.search(client, input) + return adapter.search(client, input, origin) } /** True when the provider's plain-text search matches only items containing every query word. */ diff --git a/apps/sim/lib/sim-search/live/scoped-search.test.ts b/apps/sim/lib/sim-search/live/scoped-search.test.ts index 0c17f5b881c..a1f3fdb8a54 100644 --- a/apps/sim/lib/sim-search/live/scoped-search.test.ts +++ b/apps/sim/lib/sim-search/live/scoped-search.test.ts @@ -38,4 +38,61 @@ describe('scoped native query composition', () => { ) expect(scopeAtlassianQuery('space = TEAM')).toBe('space = TEAM') }) + it.each(['jama', 'flow'] as const)( + 'targets approved %s projects with bounded fanout and no scope widening', + async (provider) => { + const search = async (request: NativeSearchInput) => ({ + documents: [ + { + id: `item-${request.native?.project ?? 'unscoped'}`, + container: request.native?.project, + title: 'Current item', + url: 'https://provider.example/item', + content: 'Current source content', + }, + ], + }) + const policyInput: NativeSearchInput = { + ...input(['1', '2', '3', '4', '5', '6', '7', '8']), + scopes: [], + policy: { + ...defaultLiveSearchPolicy(), + mode: 'selected', + included: ['1', '2', '3', '4', '5', '6', '7', '8'], + excluded: ['2'], + }, + } + const result = await searchWithinPolicy(provider, null, policyInput, search) + expect(result.documents.map((document) => document.container)).toEqual([ + '1', + '3', + '4', + '5', + '6', + '7', + ]) + expect(result.partial).toBe(true) + expect( + await searchWithinPolicy( + provider, + null, + { + ...policyInput, + native: { provider, query: 'incident', project: '2' }, + }, + search + ) + ).toEqual({ documents: [] }) + const narrowed = await searchWithinPolicy( + provider, + null, + { + ...policyInput, + native: { provider, query: 'incident', project: '8', cursor: '10' }, + }, + search + ) + expect(narrowed.documents.map((document) => document.container)).toEqual(['8']) + } + ) }) diff --git a/apps/sim/lib/sim-search/live/scoped-search.ts b/apps/sim/lib/sim-search/live/scoped-search.ts index 9bda4dbe251..51acaf5a093 100644 --- a/apps/sim/lib/sim-search/live/scoped-search.ts +++ b/apps/sim/lib/sim-search/live/scoped-search.ts @@ -136,7 +136,13 @@ export async function searchWithinPolicy( ) return { ...result, partial: result.partial || targets.length > 6 } } - if (provider === 'gitlab' || provider === 'google_calendar' || provider === 'coda') { + if ( + provider === 'gitlab' || + provider === 'google_calendar' || + provider === 'coda' || + provider === 'jama' || + provider === 'flow' + ) { const canonical = (id: string) => (provider === 'coda' ? `superhuman://docs/${id}` : id) const requested = provider === 'coda' && input.native?.project diff --git a/apps/sim/lib/sim-search/live/service-session.ts b/apps/sim/lib/sim-search/live/service-session.ts index b30ce33124b..e39c54c9ad3 100644 --- a/apps/sim/lib/sim-search/live/service-session.ts +++ b/apps/sim/lib/sim-search/live/service-session.ts @@ -101,8 +101,11 @@ export async function createLiveServiceSession(input: { })), } } + const origin = LIVE_SEARCH_PROVIDER_CATALOG[provider].origin + if (!origin) + throw new NativeSearchError('unavailable', 'This provider requires a personal connection.') const client = createNativeClient({ - origin: LIVE_SEARCH_PROVIDER_CATALOG[provider].origin, + origin, accessToken: token.accessToken, signal, pool, diff --git a/apps/sim/lib/sim-search/live/source-catalog.ts b/apps/sim/lib/sim-search/live/source-catalog.ts index 9ce8a89df73..ee5249749e6 100644 --- a/apps/sim/lib/sim-search/live/source-catalog.ts +++ b/apps/sim/lib/sim-search/live/source-catalog.ts @@ -7,6 +7,8 @@ import { findCredentialGroupProviderFromProviderId, isCredentialGroupStandardOAuthProvider, } from '@/lib/credential-groups/providers' +import { isPersonalOrganizationServiceAccount } from '@/lib/credentials/service-account-provider-ids' +import { getServiceConfigByServiceId } from '@/lib/oauth/utils' import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors' import { isManagedSearchMcpProvider, @@ -32,9 +34,21 @@ export const LIVE_SEARCH_SOURCE_TYPES: readonly (readonly [ Pick, ])[] = LIVE_SEARCH_PROVIDER_IDS.map((type) => { const managed = liveSearchMcpConnector(type) + const service = getServiceConfigByServiceId(type) + const nativeMeta = + CONNECTOR_META_REGISTRY[type] ?? + (service + ? { + id: type, + name: service.name, + icon: service.icon, + description: service.description, + } + : undefined) const meta = managed ? { ...getManagedMcpConnector(managed), icon: getManagedMcpConnectorIcon(managed) } - : CONNECTOR_META_REGISTRY[type] + : nativeMeta + if (!meta) throw new Error(`Missing live Search metadata for ${type}`) return [type, meta] as const }).sort(([, left], [, right]) => left.name.localeCompare(right.name)) @@ -57,6 +71,17 @@ export function getLiveSearchAccessAvailability( } ) { const meta = CONNECTOR_META_REGISTRY[type] + const saved = liveSearchSavedMemberProvider(type) + const deployment = integrationAvailability.get(type) + if (saved) + return { + admin: false, + members: + context.isIntegrationAvailabilityReady && + context.memberAccessAvailable && + deployment?.state !== 'unavailable' && + deployment?.state !== 'misconfigured', + } const access = meta ? getConnectorAccessAvailability(meta, integrationAvailability, context) : { admin: false, members: false } @@ -72,3 +97,12 @@ export function getLiveSearchAccessAvailability( : access.members), } } + +/** Member setup uses the existing saved-credential form for providers without browser OAuth. */ +export function liveSearchSavedMemberProvider(type: string): string | null { + if (liveSearchMcpConnector(type)) return null + const provider = LIVE_SEARCH_PROVIDER_IDS.find((id) => id === type) + if (!provider) return null + const definition = LIVE_SEARCH_PROVIDER_CATALOG[provider] + return definition.credentialProviderIds.find(isPersonalOrganizationServiceAccount) ?? null +} diff --git a/apps/sim/lib/sim-search/live/types.ts b/apps/sim/lib/sim-search/live/types.ts index a573634cf97..171d6502354 100644 --- a/apps/sim/lib/sim-search/live/types.ts +++ b/apps/sim/lib/sim-search/live/types.ts @@ -24,6 +24,8 @@ export interface LiveAccount { export type NativeAccessReference = Pick export interface NativeReadOptions { + /** Trusted provider destination resolved from the saved connection. */ + origin?: string policy: LiveSearchPolicy filters?: WorkspaceSearchFilters signal: AbortSignal @@ -33,6 +35,8 @@ export interface NativeReadOptions { } export interface NativeDocument { + /** Binds self-hosted document IDs to the credential's current provider tenant and identity. */ + connectionBinding?: string /** Server-only references whose content must pass a fresh check before projection. */ accessDependencies?: readonly NativeAccessReference[] /** diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index ad93dc810f7..9ed5fe0f53d 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -7,6 +7,7 @@ import { getValueAtPath, isRecordLike } from '@sim/utils/object' import { and, asc, count, eq, gt, inArray } from 'drizzle-orm' import { dispatchBackgroundTask } from '@/lib/core/async-jobs/background-task' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' import { type FunctionalExecutionDataSource, getFunctionalBlockOutput, @@ -280,7 +281,7 @@ async function processBackfillPage(opts: { export async function runTableBackfill(payload: TableBackfillPayload): Promise { const { jobId, tableId, groupId, outputs, overwrite, actorUserId, capabilityGovernedUserId } = payload - const requestId = generateId().slice(0, 8) + const requestId = generateRequestId() try { const table = await getTableById(tableId, { includeArchived: true }) @@ -321,16 +322,16 @@ export async function runTableBackfill(payload: TableBackfillPayload): Promise {}) void appendTableEvent({ kind: 'job', @@ -406,7 +407,8 @@ export async function maybeBackfillGroupOutputs(opts: { const claimed = await markTableJobRunning(table.id, jobId, 'backfill', jobPayload) if (!claimed) { logger.warn( - `[${requestId}] Skipping backfill for table ${table.id} group ${groupId}: another job is running` + `Skipping backfill for table ${table.id} group ${groupId}: another job is running`, + { requestId } ) return } @@ -432,9 +434,9 @@ export async function maybeBackfillGroupOutputs(opts: { // the data stays backfillable. const { releaseJobClaim } = await import('@/lib/table/jobs/service') await releaseJobClaim(table.id, jobId).catch(() => {}) - logger.warn( - `[${requestId}] Backfill dispatch failed for table ${table.id} group ${groupId}; skipping`, - { error: getErrorMessage(error) } - ) + logger.warn(`Backfill dispatch failed for table ${table.id} group ${groupId}; skipping`, { + requestId, + error: getErrorMessage(error), + }) } } diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 6c70903dc6f..a1dc18d5cd5 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -239,7 +239,7 @@ export async function addTableColumn( ) ) - logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) + logger.info(`Added column "${column.name}" to table ${tableId}`, { requestId }) return { ...table, @@ -324,7 +324,8 @@ export async function renameColumn( ) logger.info( - `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` + `Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}`, + { requestId } ) return { ...table, schema: updatedSchema, updatedAt: now } }, @@ -389,12 +390,14 @@ function stripColumnDataInBackground( }) }) logger.info( - `[${requestId}] Background-stripped deleted column data [${columnIds.join(', ')}] from table ${tableId}` + `Background-stripped deleted column data [${columnIds.join(', ')}] from table ${tableId}`, + { requestId } ) } catch (err) { logger.error( - `[${requestId}] Background column-data strip failed for table ${tableId} [${columnIds.join(', ')}]:`, - err + `Background column-data strip failed for table ${tableId} [${columnIds.join(', ')}]:`, + err, + { requestId } ) } })() @@ -485,7 +488,7 @@ export async function deleteColumn( }) } - logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) + logger.info(`Deleted column "${actualName}" from table ${data.tableId}`, { requestId }) return { def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, @@ -982,7 +985,8 @@ export async function updateColumnType( ) logger.info( - `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` + `Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}`, + { requestId } ) return { ...table, schema: updatedSchema, updatedAt: now } @@ -1052,9 +1056,9 @@ export async function updateColumnConstraints( ) ) - logger.info( - `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` - ) + logger.info(`Updated constraints for column "${column.name}" in table ${data.tableId}`, { + requestId, + }) return { ...table, schema: updatedSchema, updatedAt: now } }, @@ -1245,9 +1249,9 @@ export async function updateColumnOptions( const updated = await persistColumns(trx, table, updatedColumns) - logger.info( - `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` - ) + logger.info(`Updated options for column "${column.name}" in table ${data.tableId}`, { + requestId, + }) return updated }, @@ -1341,7 +1345,8 @@ export async function updateColumnCurrency( ) logger.info( - `[${requestId}] Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}` + `Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}`, + { requestId } ) return { ...table, schema: updatedSchema, updatedAt: now } diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index d6823ae6fac..7b164b7fb7d 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' +import { generateRequestId } from '@/lib/core/utils/request' import type { Filter, TableDefinition } from '@/lib/table' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { appendTableEvent, signalTableRowsChanged } from '@/lib/table/events' @@ -63,7 +63,7 @@ export interface TableDeletePayload { */ export async function runTableDelete(payload: TableDeletePayload): Promise { const { jobId, tableId, workspaceId, filter, excludeRowIds, cutoff, maxRows } = payload - const requestId = generateId().slice(0, 8) + const requestId = generateRequestId() const budget = maxRows ?? Number.POSITIVE_INFINITY // Whether any row was actually deleted this run. Signalled in `finally` so open editors refetch the @@ -82,7 +82,8 @@ export async function runTableDelete(payload: TableDeletePayload): Promise // releases the job slot without burning this task's remaining // `maxAttempts` on a deterministic failure. const cancelForLock = async (processedSoFar: number): Promise => { - logger.info(`[${requestId}] Delete job stopped — table is delete-locked`, { + logger.info('Delete job stopped — table is delete-locked', { + requestId, tableId, jobId, processedSoFar, @@ -238,19 +239,17 @@ export async function runTableDelete(payload: TableDeletePayload): Promise status: 'ready', progress: processed, }) - logger.info(`[${requestId}] Delete complete`, { tableId, rows: processed }) + logger.info('Delete complete', { requestId, tableId, rows: processed }) } else { - logger.info( - `[${requestId}] Delete finished but no longer owns the run (canceled/superseded)`, - { - tableId, - jobId, - } - ) + logger.info('Delete finished but no longer owns the run (canceled/superseded)', { + requestId, + tableId, + jobId, + }) } } catch (err) { if (err instanceof JobSupersededError) { - logger.info(`[${requestId}] Delete superseded by a newer run; stopping`, { tableId, jobId }) + logger.info('Delete superseded by a newer run; stopping', { requestId, tableId, jobId }) return } // Rethrow the root cause, not the wrapper: drizzle query errors embed the full SQL + params @@ -259,7 +258,7 @@ export async function runTableDelete(payload: TableDeletePayload): Promise // must already be the thrown error's own `message`. const cause = toError(err).cause const error = cause ? toError(cause) : toError(err) - logger.error(`[${requestId}] Delete failed for table ${tableId}:`, error) + logger.error(`Delete failed for table ${tableId}:`, error, { requestId }) throw error } finally { // Guaranteed final grid refetch on every exit — completion, cancel/supersede, mid-batch lock, or a diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 804725d82e9..f41c57496ab 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -428,7 +428,7 @@ export async function dispatcherStep( ): Promise { const dispatch = await readDispatch(dispatchId) if (!dispatch) { - logger.warn(`[${dispatchId}] dispatch row missing — aborting`) + logger.warn('dispatch row missing — aborting', { dispatchId }) return 'done' } if (dispatch.status === 'cancelled' || dispatch.status === 'complete') return 'done' @@ -436,7 +436,7 @@ export async function dispatcherStep( const { getTableById } = await import('@/lib/table/service') const table = await getTableById(dispatch.tableId) if (!table) { - logger.warn(`[${dispatchId}] table ${dispatch.tableId} missing — completing dispatch`) + logger.warn(`table ${dispatch.tableId} missing — completing dispatch`, { dispatchId }) await completeDispatchIfActive(dispatchId) return 'done' } @@ -482,7 +482,7 @@ export async function dispatcherStep( * completion path and overwrite `cancelled` with `complete`. */ if (claimed.length === 0) { - logger.info(`[${dispatchId}] dispatch was cancelled before this step claimed it`) + logger.info('dispatch was cancelled before this step claimed it', { dispatchId }) return 'done' } // Announce the dispatch the moment it starts — before the first window's @@ -710,9 +710,7 @@ export async function dispatcherStep( try { await queue.batchEnqueueAndWait('workflow-group-cell', items) } catch (err) { - logger.error(`[${dispatchId}] batch dispatch failed`, { - error: toError(err).message, - }) + logger.error('batch dispatch failed', { dispatchId, error: toError(err).message }) // These rows never actually ran, so they must not consume the row cap — // otherwise a transient failure on the only window of a `max: N` run would // exhaust the budget and complete the dispatch with zero rows started. diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index 7641dff079b..4b41848fa8a 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv' +import { generateRequestId } from '@/lib/core/utils/request' import { namedRowMapper } from '@/lib/table/cell-format' import { getColumnId } from '@/lib/table/column-keys' import { appendTableEvent } from '@/lib/table/events' @@ -47,7 +47,7 @@ export interface TableExportPayload { */ export async function runTableExport(payload: TableExportPayload): Promise { const { jobId, tableId, workspaceId, format } = payload - const requestId = generateId().slice(0, 8) + const requestId = generateRequestId() let handle: MultipartUploadHandle | null = null let uploadedKey: string | null = null @@ -141,14 +141,15 @@ export async function runTableExport(payload: TableExportPayload): Promise status: 'ready', progress: exported, }) - logger.info(`[${requestId}] Export complete`, { tableId, rows: exported, format }) + logger.info('Export complete', { requestId, tableId, rows: exported, format }) } else { // Canceled at the very end — the file is orphaned; remove it (janitor would otherwise // only catch it via the pruned job's resultKey). try { await deleteFile({ key: uploaded.key, context: 'workspace' }) } catch (cleanupError) { - logger.warn(`[${requestId}] Failed to delete superseded export`, { + logger.warn('Failed to delete superseded export', { + requestId, tableId, workspaceId, jobId, @@ -156,7 +157,7 @@ export async function runTableExport(payload: TableExportPayload): Promise error: getErrorMessage(cleanupError, 'Unknown cleanup error'), }) } - logger.info(`[${requestId}] Export finished but no longer owns the run`, { tableId, jobId }) + logger.info('Export finished but no longer owns the run', { requestId, tableId, jobId }) } } catch (err) { // A partial/orphaned upload from this attempt is useless — clean it up best-effort. An @@ -166,7 +167,8 @@ export async function runTableExport(payload: TableExportPayload): Promise try { await deleteFile({ key: uploadedKey, context: 'workspace' }) } catch (cleanupError) { - logger.warn(`[${requestId}] Failed to delete incomplete export`, { + logger.warn('Failed to delete incomplete export', { + requestId, tableId, workspaceId, jobId, @@ -178,7 +180,8 @@ export async function runTableExport(payload: TableExportPayload): Promise try { await handle.abort() } catch (cleanupError) { - logger.warn(`[${requestId}] Failed to abort incomplete export`, { + logger.warn('Failed to abort incomplete export', { + requestId, tableId, workspaceId, jobId, @@ -187,14 +190,15 @@ export async function runTableExport(payload: TableExportPayload): Promise } } if (err instanceof JobSupersededError) { - logger.info(`[${requestId}] Export superseded/canceled; stopping`, { tableId, jobId }) + logger.info('Export superseded/canceled; stopping', { requestId, tableId, jobId }) } else { const message = getErrorMessage(err, 'Export failed') - logger.error(`[${requestId}] Export failed for table ${tableId}:`, err) + logger.error(`Export failed for table ${tableId}:`, err, { requestId }) try { await markJobFailedInWorkspace(tableId, workspaceId, jobId, message) } catch (failureError) { - logger.error(`[${requestId}] Failed to mark export job failed`, { + logger.error('Failed to mark export job failed', { + requestId, tableId, workspaceId, jobId, diff --git a/apps/sim/lib/table/export-stream.ts b/apps/sim/lib/table/export-stream.ts index 307f2f7534d..f6c4f5b44c5 100644 --- a/apps/sim/lib/table/export-stream.ts +++ b/apps/sim/lib/table/export-stream.ts @@ -74,12 +74,9 @@ export function createTableExportStream( if (format === 'json') controller.enqueue(encoder.encode(']')) controller.close() - logger.info(`[${requestId}] Exported table ${table.id}`, { - format, - rowCount: table.rowCount, - }) + logger.info(`Exported table ${table.id}`, { requestId, format, rowCount: table.rowCount }) } catch (err) { - logger.error(`[${requestId}] Export failed for table ${table.id}`, err) + logger.error(`Export failed for table ${table.id}`, err, { requestId }) controller.error(err) } }, diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index fc5276a6c28..327034e11d6 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -170,7 +170,7 @@ export async function bulkInsertImportBatch( if (inserted !== rowsToInsert.length) { throw new Error('Bulk table import inserted an unexpected row count') } - logger.info(`[${requestId}] Bulk-imported ${inserted} rows into table ${data.tableId}`) + logger.info(`Bulk-imported ${inserted} rows into table ${data.tableId}`, { requestId }) return { inserted, lastOrderKey: orderKeys[orderKeys.length - 1] ?? data.afterOrderKey ?? null, diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index dd4efa4fd2d..0185bc8ed60 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -1,9 +1,9 @@ import { type Readable, Transform } from 'node:stream' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, @@ -101,7 +101,7 @@ export interface TableImportPayload { export async function runTableImport(payload: TableImportPayload): Promise { const { importId, tableId, workspaceId, userId, fileKey, fileName, delimiter, mode } = payload const storageContext = payload.storageContext ?? 'workspace' - const requestId = generateId().slice(0, 8) + const requestId = generateRequestId() // Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks // open until it times out. let source: Readable | undefined @@ -438,7 +438,7 @@ export async function runTableImport(payload: TableImportPayload): Promise }, { groups: { workspace: workspaceId } } ) - logger.warn(`[${requestId}] Import has no data rows`, { tableId, fileName }) + logger.warn('Import has no data rows', { requestId, tableId, fileName }) return } await resolveSetup() @@ -473,31 +473,27 @@ export async function runTableImport(payload: TableImportPayload): Promise }, { groups: { workspace: workspaceId } } ) - logger.info(`[${requestId}] Import complete`, { tableId, fileName, mode, rows: inserted }) + logger.info('Import complete', { requestId, tableId, fileName, mode, rows: inserted }) } else { - logger.info( - `[${requestId}] Import finished but no longer owns the run (canceled/superseded)`, - { - tableId, - importId, - } - ) + logger.info('Import finished but no longer owns the run (canceled/superseded)', { + requestId, + tableId, + importId, + }) } } catch (err) { if (err instanceof ImportSupersededError) { // A newer import owns the table now — leave its status alone and just stop. - logger.info(`[${requestId}] Import superseded by a newer run; stopping`, { - tableId, - importId, - }) + logger.info('Import superseded by a newer run; stopping', { requestId, tableId, importId }) } else { const message = getErrorMessage(err, 'Import failed') - logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) + logger.error(`Import failed for table ${tableId}:`, err, { requestId }) // Scoped to importId — a no-op if a newer import has taken over. try { await markJobFailedInWorkspace(tableId, workspaceId, importId, message) } catch (failureError) { - logger.error(`[${requestId}] Failed to mark import job failed`, { + logger.error('Failed to mark import job failed', { + requestId, tableId, workspaceId, importId, @@ -541,7 +537,8 @@ export async function runTableImport(payload: TableImportPayload): Promise rejectedSamples: rejections.summary.rejectedSamples, }) } catch (summaryError) { - logger.error(`[${requestId}] Failed to record import rejections`, { + logger.error('Failed to record import rejections', { + requestId, tableId, importId, error: getErrorMessage(summaryError, 'Unknown rejection-summary error'), @@ -553,7 +550,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // persistent workspace files (deleteSourceFile: false). if (payload.deleteSourceFile !== false) { await deleteFile({ key: fileKey, context: storageContext }).catch((err) => { - logger.warn(`[${requestId}] Failed to delete imported file`, { fileKey, err }) + logger.warn('Failed to delete imported file', { requestId, fileKey, err }) }) } } diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 48f8f5ffd3d..bc35e5beaa9 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -247,7 +247,8 @@ export async function performUpdateTableColumn( ) } } catch (error) { - logger.error(`[${requestId}] Failed to update column "${columnName}" on table ${tableId}`, { + logger.error(`Failed to update column "${columnName}" on table ${tableId}`, { + requestId, error, }) return classify(error) diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts index badac316e35..3777657b700 100644 --- a/apps/sim/lib/table/orchestration/import.ts +++ b/apps/sim/lib/table/orchestration/import.ts @@ -81,7 +81,7 @@ function classifyImportFailure(error: unknown, requestId: string, tableId: strin return { ...fail(error.message, 'locked'), lock: error.lock } } if (error instanceof OrchestrationError) return fail(error.message, error.code) - logger.error(`[${requestId}] CSV import failed for table ${tableId}`, { error }) + logger.error(`CSV import failed for table ${tableId}`, { requestId, error }) return fail(toError(error).message, 'internal') } @@ -381,7 +381,8 @@ export async function performTableCsvImport( ) const rejected = rejectionFields(rejections, cellsRejected) if (rejected.rejections) { - logger.warn(`[${requestId}] CSV import lost source data`, { + logger.warn('CSV import lost source data', { + requestId, tableId: table.id, fileName, ...rejected.rejections, @@ -415,7 +416,8 @@ export async function performTableCsvImport( // global db connection and would otherwise see no rows. dispatchAfterBatchInsert(finalTable, inserted, requestId, userId, capabilityGovernedUserId) - logger.info(`[${requestId}] Append CSV imported`, { + logger.info('Append CSV imported', { + requestId, tableId: table.id, fileName, inserted: inserted.length, @@ -433,7 +435,8 @@ export async function performTableCsvImport( requestId ) - logger.info(`[${requestId}] Replace CSV imported`, { + logger.info('Replace CSV imported', { + requestId, tableId: table.id, fileName, deleted: result.deletedCount, @@ -645,7 +648,8 @@ export async function performCreateTableFromCsv( return classifyImportFailure(error, requestId, state?.table.id ?? 'unknown') } - logger.info(`[${requestId}] CSV imported`, { + logger.info('CSV imported', { + requestId, tableId: state.table.id, fileName, columns: state.schema.columns.length, @@ -654,7 +658,8 @@ export async function performCreateTableFromCsv( const rejected = rejectionFields(rejections.summary, cellsRejected) if (rejected.rejections) { - logger.warn(`[${requestId}] CSV import lost source data`, { + logger.warn('CSV import lost source data', { + requestId, tableId: state.table.id, fileName, ...rejected.rejections, diff --git a/apps/sim/lib/table/orchestration/restore.ts b/apps/sim/lib/table/orchestration/restore.ts index 1aff4de5da2..14764d89078 100644 --- a/apps/sim/lib/table/orchestration/restore.ts +++ b/apps/sim/lib/table/orchestration/restore.ts @@ -36,7 +36,7 @@ export async function performRestoreTable( await restoreTable(tableId, requestId) const table = (await getTableById(tableId)) ?? archivedTable - logger.info(`[${requestId}] Restored table ${tableId}`) + logger.info(`Restored table ${tableId}`, { requestId }) recordAudit({ workspaceId: archivedTable.workspaceId, @@ -54,7 +54,7 @@ export async function performRestoreTable( return { success: true, table } } catch (error) { - logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) + logger.error(`Failed to restore table ${tableId}`, { requestId, error }) if (error instanceof TableConflictError) { return { success: false, error: error.message, errorCode: 'conflict' } } diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts index 3c0abcf6ae0..ce62bf398c5 100644 --- a/apps/sim/lib/table/orchestration/tables.ts +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -62,7 +62,7 @@ export async function performDeleteTable( if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } } - logger.error(`[${requestId}] Failed to delete table ${table.id}`, { error }) + logger.error(`Failed to delete table ${table.id}`, { requestId, error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } @@ -126,7 +126,7 @@ export async function performDeleteTableRow( if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } } - logger.error(`[${requestId}] Failed to delete row ${rowId} from table ${table.id}`, { error }) + logger.error(`Failed to delete row ${rowId} from table ${table.id}`, { requestId, error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -164,7 +164,7 @@ function classifyTableMutation(error: unknown, requestId: string, tableId: strin if (error instanceof OrchestrationError) { return { success: false as const, error: error.message, errorCode: error.code } } - logger.error(`[${requestId}] Table mutation failed for ${tableId}`, { error }) + logger.error(`Table mutation failed for ${tableId}`, { requestId, error }) return { success: false as const, error: toError(error).message, diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 1c0c18a1ff1..0f7a13d12a4 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -251,7 +251,7 @@ export async function insertRow( limit: rowLimit, }) - logger.info(`[${requestId}] Inserted row ${rowId} into table ${data.tableId}`) + logger.info(`Inserted row ${rowId} into table ${data.tableId}`, { requestId }) const insertedRow: TableRow = { id: row.id, @@ -282,7 +282,7 @@ export async function insertRow( requestId, triggeredByUserId: data.userId, capabilityGovernedUserId: data.capabilityGovernedUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err)) + }).catch((err) => logger.error('auto-dispatch (insertRow) failed:', err, { requestId })) return insertedRow } @@ -442,7 +442,7 @@ export async function batchInsertRowsWithTx( }, }) - logger.info(`[${requestId}] Batch inserted ${data.rows.length} rows into table ${data.tableId}`) + logger.info(`Batch inserted ${data.rows.length} rows into table ${data.tableId}`, { requestId }) const result: TableRow[] = insertedRows.map((r) => ({ id: r.id, @@ -495,7 +495,7 @@ export function dispatchAfterBatchInsert( requestId, triggeredByUserId: actorUserId, capabilityGovernedUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) + }).catch((err) => logger.error('auto-dispatch (batchInsertRows) failed:', err, { requestId })) } /** @@ -691,7 +691,8 @@ export async function replaceTableRowsWithTx( } logger.info( - `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedCount}, inserted ${insertedCount}` + `Replaced rows in table ${data.tableId}: deleted ${deletedCount}, inserted ${insertedCount}`, + { requestId } ) return { deletedCount, insertedCount } @@ -959,9 +960,9 @@ export async function upsertRow( } }) - logger.info( - `[${requestId}] Upserted (${result.operation}) row ${result.row.id} in table ${data.tableId}` - ) + logger.debug(`Upserted (${result.operation}) row ${result.row.id} in table ${data.tableId}`, { + requestId, + }) if (result.operation === 'insert') { notifyTableRowUsage({ @@ -1002,7 +1003,7 @@ export async function upsertRow( requestId, triggeredByUserId: data.userId, capabilityGovernedUserId: data.capabilityGovernedUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err)) + }).catch((err) => logger.error('auto-dispatch (upsertRow) failed:', err, { requestId })) return result } @@ -1175,7 +1176,8 @@ export async function findRowMatches( })) logger.info( - `[${requestId}] Find "${options.q}" in table ${table.id}: ${matches.length} match(es)${truncated ? ' (truncated)' : ''}` + `Find "${options.q}" in table ${table.id}: ${matches.length} match(es)${truncated ? ' (truncated)' : ''}`, + { requestId } ) return { matches, truncated } @@ -1331,7 +1333,8 @@ export async function queryRows( : null logger.debug( - `[${requestId}] Queried ${rows.length} rows from table ${table.id} (total: ${totalCount}, bytes: ${fetched.bytes}, more: ${fetched.hasMore})` + `Queried ${rows.length} rows from table ${table.id} (total: ${totalCount}, bytes: ${fetched.bytes}, more: ${fetched.hasMore})`, + { requestId } ) const mappedRows = rows.map((r) => ({ @@ -2111,7 +2114,7 @@ export async function updateRow( throw err } - logger.info(`[${requestId}] Updated row ${data.rowId} in table ${data.tableId}`) + logger.info(`Updated row ${data.rowId} in table ${data.tableId}`, { requestId }) const updatedRow: TableRow = { ...toRowSummary(persistedRow), @@ -2169,7 +2172,7 @@ export async function updateRow( capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { - logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err) + logger.error('cancel+rerun for in-flight downstream groups failed:', err, { requestId }) } })() } @@ -2182,7 +2185,7 @@ export async function updateRow( requestId, triggeredByUserId: data.actorUserId, capabilityGovernedUserId: data.capabilityGovernedUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err)) + }).catch((err) => logger.error('auto-dispatch (updateRow) failed:', err, { requestId })) return updatedRow } @@ -2210,7 +2213,7 @@ export async function deleteRow( }) if (!deleted) throw new OrchestrationError('not_found', 'Row not found') - logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`) + logger.info(`Deleted row ${rowId} from table ${table.id}`, { requestId }) void dispatchDeleteTriggers(table, [deleted], requestId) } @@ -2400,8 +2403,8 @@ async function persistBulkUpdateBatch(params: { } if (skippedRowIds.length > 0) { logger.warn( - `[${requestId}] Skipping rows concurrently changed to values that cannot accept the bulk patch`, - { tableId: table.id, rowIds: skippedRowIds } + 'Skipping rows concurrently changed to values that cannot accept the bulk patch', + { requestId, tableId: table.id, rowIds: skippedRowIds } ) } @@ -2474,7 +2477,7 @@ function dispatchBulkUpdateEffects( triggeredByUserId: actorUserId, capabilityGovernedUserId, }).catch((error) => - logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, error) + logger.error('auto-dispatch (updateRowsByFilter) failed:', error, { requestId }) ) } @@ -2593,7 +2596,7 @@ export async function updateRowsByFilter( if (batchRows.length < TABLE_LIMITS.UPDATE_BATCH_SIZE) break } - logger.info(`[${requestId}] Updated ${affectedRowIds.length} rows in table ${table.id}`) + logger.info(`Updated ${affectedRowIds.length} rows in table ${table.id}`, { requestId }) return { affectedCount: affectedRowIds.length, affectedRowIds } } @@ -2636,7 +2639,7 @@ export async function updateRowsByFilter( }) const { affectedRowIds } = persisted - logger.info(`[${requestId}] Updated ${affectedRowIds.length} rows in table ${table.id}`) + logger.info(`Updated ${affectedRowIds.length} rows in table ${table.id}`, { requestId }) dispatchBulkUpdateEffects( persisted.table, persisted.rows, @@ -2917,7 +2920,7 @@ export async function batchUpdateRows( }) }) - logger.info(`[${requestId}] Batch updated ${affectedRowIds.length} rows in table ${data.tableId}`) + logger.info(`Batch updated ${affectedRowIds.length} rows in table ${data.tableId}`, { requestId }) const affectedRowIdSet = new Set(affectedRowIds) const oldRowsForTrigger = new Map( @@ -2975,10 +2978,9 @@ export async function batchUpdateRows( }) } } catch (err) { - logger.error( - `[${requestId}] cancel+rerun for in-flight downstream groups (batch) failed:`, - err - ) + logger.error('cancel+rerun for in-flight downstream groups (batch) failed:', err, { + requestId, + }) } })() } @@ -2992,7 +2994,7 @@ export async function batchUpdateRows( requestId, triggeredByUserId: data.actorUserId, capabilityGovernedUserId: data.capabilityGovernedUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) + }).catch((err) => logger.error('auto-dispatch (batchUpdateRows) failed:', err, { requestId })) } return { @@ -3083,7 +3085,7 @@ export async function deleteRowsByFilter( if (deletedRowIds.length === 0) return { affectedCount: 0, affectedRowIds: [] } - logger.info(`[${requestId}] Deleted ${deletedRowIds.length} rows from table ${table.id}`) + logger.info(`Deleted ${deletedRowIds.length} rows from table ${table.id}`, { requestId }) return { affectedCount: deletedRowIds.length, @@ -3118,7 +3120,7 @@ export async function deleteRowsByIds( const deletedIdSet = new Set(deletedIds) const missingRowIds = uniqueRequestedRowIds.filter((id) => !deletedIdSet.has(id)) - logger.info(`[${requestId}] Deleted ${deletedIds.length} rows by ID from table ${data.tableId}`) + logger.info(`Deleted ${deletedIds.length} rows by ID from table ${data.tableId}`, { requestId }) return { deletedCount: deletedIds.length, deletedRowIds: deletedIds, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 7f216babd45..5e1390787d6 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -726,7 +726,7 @@ export async function createTable( }) } - logger.info(`[${requestId}] Created table ${tableId} in workspace ${data.workspaceId}`) + logger.info(`Created table ${tableId} in workspace ${data.workspaceId}`, { requestId }) // Live tables list: tell everyone viewing this workspace's tables to refetch. await notifyWorkspaceTablesChanged(data.workspaceId) @@ -842,7 +842,8 @@ export async function addTableColumnsWithTx( ) logger.info( - `[${requestId}] Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}` + `Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}`, + { requestId } ) return { @@ -921,7 +922,7 @@ export async function renameTable( const { workspaceId } = result[0] - logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) + logger.info(`Renamed table ${tableId} to "${newName}"`, { requestId }) // Live tables list: a rename changes the list result, so everyone viewing refetches. if (workspaceId && !options?.skipNotify) await notifyWorkspaceTablesChanged(workspaceId) @@ -958,7 +959,7 @@ export async function updateTableDescription( throw new OrchestrationError('not_found', `Table ${tableId} not found`) } - logger.info(`[${requestId}] Updated description for table ${tableId}`) + logger.info(`Updated description for table ${tableId}`, { requestId }) await notifyWorkspaceTablesChanged(workspaceId) return result[0] } @@ -1020,7 +1021,7 @@ export async function moveTableToFolder( const { name } = result[0] - logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) + logger.info(`Moved table ${tableId} to folder ${folderId ?? 'root'}`, { requestId }) // Live tables list: a move changes each table's folder placement in the list result. if (options?.notify ?? true) await notifyWorkspaceTablesChanged(workspaceId) @@ -1056,10 +1057,10 @@ export async function updateTableLocks( }) await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => { - logger.warn(`[${requestId}] Failed to emit lock-change event for table ${tableId}`, { error }) + logger.warn(`Failed to emit lock-change event for table ${tableId}`, { requestId, error }) }) - logger.info(`[${requestId}] Updated locks for table ${tableId}`) + logger.info(`Updated locks for table ${tableId}`, { requestId }) return { table: updated, previousLocks } } @@ -1194,7 +1195,7 @@ export async function deleteTable( } // Otherwise the table is missing or already archived — a silent no-op, as before. } - logger.info(`[${requestId}] Archived table ${tableId}`) + logger.info(`Archived table ${tableId}`, { requestId }) // Live tables list: only on a genuine archive (a no-op/already-archived delete changes nothing). // Skipped under a folder cascade — deleteFolder fires one folder-level notify for the whole subtree, // so we don't spam one relay call per archived table. @@ -1302,7 +1303,7 @@ export async function restoreTable( } } - logger.info(`[${requestId}] Restored table ${tableId} as "${attemptedRestoreName}"`) + logger.info(`Restored table ${tableId} as "${attemptedRestoreName}"`, { requestId }) // Live tables list: a restore re-adds the table to the active list. Skipped under a folder cascade — // restoreFolder fires one folder-level notify for the whole subtree (see deleteTable). diff --git a/apps/sim/lib/table/snapshot-cache.ts b/apps/sim/lib/table/snapshot-cache.ts index c4d4b135580..4a7a4183be4 100644 --- a/apps/sim/lib/table/snapshot-cache.ts +++ b/apps/sim/lib/table/snapshot-cache.ts @@ -159,18 +159,19 @@ export async function getOrCreateTableSnapshot( const head = await headObject(key, SNAPSHOT_STORAGE_CONTEXT) if (head) { - logger.info(`[${requestId}] Snapshot hit`, { tableId: table.id, version, size: head.size }) + logger.info('Snapshot hit', { requestId, tableId: table.id, version, size: head.size }) return { key, size: head.size, version } } - logger.info(`[${requestId}] Snapshot miss; materializing`, { tableId: table.id, version }) + logger.info('Snapshot miss; materializing', { requestId, tableId: table.id, version }) const size = await materialize(table, key) const after = await readRowsVersion(table.id) if (after !== version) { // The table mutated mid-scan: the bytes under `key` may be torn. Re-key to the new version and // rebuild once (or reuse if a concurrent writer already stored it); drop the stale object. - logger.info(`[${requestId}] rows_version advanced during materialize; re-keying`, { + logger.info('rows_version advanced during materialize; re-keying', { + requestId, tableId: table.id, from: version, to: after, diff --git a/apps/sim/lib/table/trigger.ts b/apps/sim/lib/table/trigger.ts index d99f1d6da1e..b43b52d2a00 100644 --- a/apps/sim/lib/table/trigger.ts +++ b/apps/sim/lib/table/trigger.ts @@ -97,7 +97,8 @@ export async function fireTableTrigger( const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') logger.info( - `[${requestId}] Firing ${matching.length} trigger(s) for ${rows.length} ${eventType} event(s) in table ${tableId}` + `Firing ${matching.length} trigger(s) for ${rows.length} ${eventType} event(s) in table ${tableId}`, + { requestId } ) for (const { webhook: webhookData, workflow: workflowData } of matching) { @@ -149,18 +150,21 @@ export async function fireTableTrigger( if (!result.success) { logger.error( - `[${eventRequestId}] Failed to fire table trigger for row ${row.id}:`, + `Failed to fire table trigger for row ${row.id}:`, result.statusCode, - result.error + result.error, + { requestId: eventRequestId } ) } } catch (error) { - logger.error(`[${eventRequestId}] Error firing table trigger for row ${row.id}:`, error) + logger.error(`Error firing table trigger for row ${row.id}:`, error, { + requestId: eventRequestId, + }) } } } } catch (error) { - logger.error(`[${requestId}] Error in fireTableTrigger:`, error) + logger.error('Error in fireTableTrigger:', error, { requestId }) } } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 1b0f2d1de9d..02718652bef 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -916,7 +916,7 @@ export async function assertWorkflowGroupsDeployable( throw new OrchestrationError('validation', describe(undeployed[0])) } for (const group of undeployed) { - logger.warn(`[${options.requestId}] Skipping auto-run: ${describe(group)}`) + logger.warn(`Skipping auto-run: ${describe(group)}`, { requestId: options.requestId }) } const skipped = new Set(undeployed.map((group) => group.id)) return groups.filter((group) => !skipped.has(group.id)) @@ -1109,7 +1109,8 @@ export async function runWorkflowColumn(opts: { try { await cancelDispatchById(dispatchId) } catch (cleanupErr) { - logger.error(`[Cascade] [${requestId}] failed to cancel dispatch after prep failure`, { + logger.error('[Cascade] failed to cancel dispatch after prep failure', { + requestId, dispatchId, error: toError(cleanupErr).message, }) @@ -1126,14 +1127,15 @@ export async function runWorkflowColumn(opts: { // takes its "no dispatch created" path and rolls the optimistic bump back. const current = await readDispatch(dispatchId) if (!current || current.status === 'cancelled' || current.status === 'complete') { - logger.info( - `[Cascade] [${requestId}] dispatch ${dispatchId} cancelled during prep — not firing` - ) + logger.info(`[Cascade] dispatch ${dispatchId} cancelled during prep — not firing`, { + requestId, + }) return { dispatchId: null, shouldSignalRowsChanged } } logger.info( - `[Cascade] [${requestId}] dispatch ${dispatchId} table=${tableId} groups=[${targetGroupIds.join(',')}] rows=${rowIds ? `[${rowIds.join(',')}]` : 'all'} mode=${mode}` + `[Cascade] dispatch ${dispatchId} table=${tableId} groups=[${targetGroupIds.join(',')}] rows=${rowIds ? `[${rowIds.join(',')}]` : 'all'} mode=${mode}`, + { requestId } ) if (isTriggerDevEnabled) { @@ -1154,10 +1156,7 @@ export async function runWorkflowColumn(opts: { // so the HTTP request returns instantly (mirrors the trigger.dev path's // async fan-out). void runDispatcherToCompletion(dispatchId, concurrency).catch((err) => - logger.error(`[${requestId}] dispatcher loop failed`, { - dispatchId, - error: toError(err).message, - }) + logger.error('dispatcher loop failed', { requestId, dispatchId, error: toError(err).message }) ) } diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 1107717c54a..b4d498fb1f2 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -99,7 +99,8 @@ export async function pruneStaleWorkflowGroupOutputs({ if (filtered.length === group.outputs.length) return group if (filtered.length === 0) { logger.warn( - `[${requestId}] All outputs for workflow group "${group.name ?? group.id}" in table ${t.id} reference deleted blocks; leaving group intact for user reconfiguration.` + `All outputs for workflow group "${group.name ?? group.id}" in table ${t.id} reference deleted blocks; leaving group intact for user reconfiguration.`, + { requestId } ) return group } @@ -119,7 +120,7 @@ export async function pruneStaleWorkflowGroupOutputs({ and(eq(userTableDefinitions.id, t.id), eq(userTableDefinitions.workspaceId, workspaceId)) ) - logger.info(`[${requestId}] Pruned stale workflow=${workflowId} block refs from table ${t.id}`) + logger.info(`Pruned stale workflow=${workflowId} block refs from table ${t.id}`, { requestId }) } } @@ -273,7 +274,8 @@ export async function addWorkflowGroup( ) logger.info( - `[${requestId}] Added workflow group "${data.group.id}" with ${newColumns.length} new and ${attached.size} attached output column(s) to table ${data.tableId}` + `Added workflow group "${data.group.id}" with ${newColumns.length} new and ${attached.size} attached output column(s) to table ${data.tableId}`, + { requestId } ) return { @@ -301,7 +303,7 @@ export async function addWorkflowGroup( requestId, triggeredByUserId: data.actorUserId, capabilityGovernedUserId: data.capabilityGovernedUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) + }).catch((err) => logger.error('auto-dispatch (addWorkflowGroup) failed:', err, { requestId })) } return updatedTable @@ -580,7 +582,8 @@ export async function updateWorkflowGroup( } logger.info( - `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` + `Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})`, + { requestId } ) const updatedTable: TableDefinition = { @@ -624,8 +627,9 @@ export async function updateWorkflowGroup( }) } catch (err) { logger.warn( - `[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, - err + `Backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, + err, + { requestId } ) } } @@ -643,8 +647,9 @@ export async function updateWorkflowGroup( }) } catch (err) { logger.warn( - `[${requestId}] Remap backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, - err + `Remap backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, + err, + { requestId } ) } } @@ -663,7 +668,7 @@ export async function updateWorkflowGroup( triggeredByUserId: data.actorUserId, capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => - logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) + logger.error('auto-dispatch (updateWorkflowGroup autoRun=true) failed:', err, { requestId }) ) } @@ -737,9 +742,9 @@ export async function deleteWorkflowGroup( expectedWorkspaceId: table.workspaceId, }) - logger.info( - `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` - ) + logger.info(`Deleted workflow group "${data.groupId}" from table ${data.tableId}`, { + requestId, + }) return { ...table, diff --git a/apps/sim/lib/tools/falai-pricing.test.ts b/apps/sim/lib/tools/falai-pricing.test.ts index 65d8c3187df..3b657ebc019 100644 --- a/apps/sim/lib/tools/falai-pricing.test.ts +++ b/apps/sim/lib/tools/falai-pricing.test.ts @@ -1,3 +1,5 @@ +import { createDeferred } from '@sim/testing/helpers/deferred' +import { jsonResponse } from '@sim/testing/helpers/http' import { utilsHelpersMock } from '@sim/testing/mocks/utils-helpers.mock' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -5,27 +7,17 @@ import { FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS, FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS, getFalAICostMetadata, -} from './falai-pricing' +} from '@/lib/tools/falai-pricing' // Avoid the real inter-attempt backoff so the fallback path resolves instantly. vi.mock('@sim/utils/helpers', () => utilsHelpersMock) describe('getFalAICostMetadata fallback floor', () => { - const originalFetch = global.fetch - beforeEach(() => { - // Both fal cost endpoints (billing-events + pricing estimate) fail, forcing - // the provider-cost floor selection by endpoint category. - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({}), - text: async () => 'error', - }) as unknown as typeof fetch - }) - - afterEach(() => { - global.fetch = originalFetch + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('error', { status: 500 })) + ) }) it.each([ @@ -66,10 +58,241 @@ describe('getFalAICostMetadata fallback floor', () => { expect(result.source).toBe('fallback_floor') expect(result.costDollars).toBe(FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS) }) +}) + +describe('Fal pricing bursts', () => { + let lookup = getFalAICostMetadata + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + vi.spyOn(performance, 'now').mockImplementation(() => Date.now()) + vi.resetModules() + lookup = (await import('@/lib/tools/falai-pricing')).getFalAICostMetadata + }) + + afterEach(() => vi.useRealTimers()) + + it('coalesces historical estimates while keeping receipts per job and credential', async () => { + const estimates: string[] = [] + const receipts: string[] = [] + let accountCost = 0 + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + if (String(url).includes('billing-events')) { + const id = new URL(url).searchParams.get('request_id') || '' + receipts.push(id) + return jsonResponse({ + billing_events: + id === 'exact' + ? [ + { + request_id: id, + endpoint_id: 'fal-ai/flux', + cost_estimate_nano_usd: 90000000, + }, + ] + : [], + }) + } + const key = new Headers(init?.headers).get('Authorization') || '' + estimates.push(key) + return jsonResponse({ total_cost: key === 'Key account-a' ? accountCost : 0.3 }) + }) + const request = (requestId: string, apiKey = 'account-a', endpointId = 'fal-ai/flux') => + lookup({ apiKey, endpointId, requestId }) + const burst = await Promise.all(Array.from({ length: 20 }, (_, i) => request(`job-${i}`))) + expect( + burst.every((cost) => cost.source === 'historical_estimate' && cost.costDollars === 0) + ).toBe(true) + expect(estimates).toEqual(['Key account-a']) + accountCost = 0.6 + expect(await request('cached')).toMatchObject({ costDollars: 0, source: 'historical_estimate' }) + expect(await request('exact')).toMatchObject({ + requestId: 'exact', + costDollars: 0.09, + source: 'billing_events', + }) + expect(await request('other-account', 'account-b')).toMatchObject({ costDollars: 0.3 }) + await request('other-model', 'account-a', 'fal-ai/nano-banana') + expect(estimates).toEqual(['Key account-a', 'Key account-b', 'Key account-a']) + expect(new Set(receipts).size).toBe(24) + await vi.advanceTimersByTimeAsync(300001) + expect(await request('expired')).toMatchObject({ + costDollars: 0.6, + source: 'historical_estimate', + }) + expect(estimates).toHaveLength(4) + }) + + it('honors account-scoped Retry-After across endpoints and resumes after expiry', async () => { + const estimates: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + const key = new Headers(init?.headers).get('Authorization') || '' + estimates.push(key) + return estimates.length === 1 + ? new Response('sensitive provider body', { + status: 429, + headers: { 'Retry-After': '120' }, + }) + : jsonResponse({ total_cost: 0.12 }) + }) + const request = (endpointId: string, apiKey = 'limited-key') => + lookup({ apiKey, endpointId, requestId: endpointId }) + const limited = await request('fal-ai/flux') + expect(limited.source).toBe('fallback_floor') + expect(limited.error).not.toContain('sensitive provider body') + await request('fal-ai/nano-banana') + expect(estimates).toHaveLength(1) + expect(await request('fal-ai/flux', 'other-key')).toMatchObject({ costDollars: 0.12 }) + await vi.advanceTimersByTimeAsync(119000) + await request('fal-ai/flux') + expect(estimates).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1001) + expect(await request('fal-ai/flux')).toMatchObject({ + source: 'historical_estimate', + costDollars: 0.12, + }) + expect(estimates).toHaveLength(3) + }) + + it('does not shorten an active cooldown when concurrent endpoints receive different Retry-After values', async () => { + let estimates = 0 + const second = createDeferred() + const started = createDeferred() + vi.stubGlobal('fetch', async (url: string | URL) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + estimates++ + if (estimates === 1) + return new Response(null, { status: 429, headers: { 'Retry-After': '120' } }) + if (estimates === 2) { + started.resolve() + return second.promise + } + return jsonResponse({ total_cost: 0.2 }) + }) + const request = (endpointId: string) => + lookup({ apiKey: 'shared-account', endpointId, requestId: endpointId }) + const firstLookup = request('fal-ai/flux') + const secondLookup = request('fal-ai/nano-banana') + await started.promise + await firstLookup + second.resolve(new Response(null, { status: 429, headers: { 'Retry-After': '1' } })) + await secondLookup + await vi.advanceTimersByTimeAsync(2000) + expect(await request('fal-ai/other')).toMatchObject({ source: 'fallback_floor' }) + expect(estimates).toBe(2) + await vi.advanceTimersByTimeAsync(118001) + expect(await request('fal-ai/other')).toMatchObject({ + source: 'historical_estimate', + costDollars: 0.2, + }) + }) + + it('backs off forbidden billing-event reads without blocking historical estimates', async () => { + let reads = 0 + vi.stubGlobal('fetch', async (url: string | URL) => { + if (String(url).includes('billing-events')) { + reads++ + return new Response(null, { status: 403 }) + } + return jsonResponse({ total_cost: 0.2 }) + }) + for (const requestId of ['one', 'two']) { + expect( + await lookup({ apiKey: 'limited-scope', endpointId: 'fal-ai/flux', requestId }) + ).toMatchObject({ costDollars: 0.2 }) + } + expect(reads).toBe(1) + }) - it('never bills an audio clip at the video floor', () => { - expect(FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS).toBeLessThan( - FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS + it('never bills a receipt for a different request', async () => { + vi.stubGlobal('fetch', async (url: string | URL) => + String(url).includes('billing-events') + ? jsonResponse({ + billing_events: [ + { + request_id: 'unrelated', + endpoint_id: 'fal-ai/flux', + cost_estimate_nano_usd: 8000000000, + }, + ], + }) + : jsonResponse({ total_cost: 0.1 }) + ) + expect( + await lookup({ apiKey: 'key', endpointId: 'fal-ai/flux', requestId: 'mine' }) + ).toMatchObject({ requestId: 'mine', source: 'historical_estimate', costDollars: 0.1 }) + }) + + it('retains a successful shared price after its only waiter stops', async () => { + const response = createDeferred() + const started = createDeferred() + let estimates = 0 + vi.stubGlobal('fetch', async (url: string | URL) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + estimates++ + started.resolve() + return estimates === 1 ? response.promise : jsonResponse({ total_cost: 0.9 }) + }) + const controller = new AbortController() + const args = { apiKey: 'stopped-account', endpointId: 'fal-ai/flux' } + const stopped = lookup({ ...args, requestId: 'stopped', signal: controller.signal }) + const rejected = expect(stopped).rejects.toThrow('Stopped') + await started.promise + controller.abort(new Error('Stopped')) + await rejected + response.resolve(jsonResponse({ total_cost: 0.2 })) + await vi.advanceTimersByTimeAsync(0) + expect(await lookup({ ...args, requestId: 'later' })).toMatchObject({ costDollars: 0.2 }) + expect(estimates).toBe(1) + }) + + it('isolates a cancelled waiter from a shared estimate and bounds hung producers', async () => { + const pending = createDeferred() + const started = createDeferred() + let estimates = 0 + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + if (String(url).includes('billing-events')) return jsonResponse({ billing_events: [] }) + estimates++ + started.resolve() + init?.signal?.addEventListener('abort', () => pending.reject(init.signal?.reason), { + once: true, + }) + return pending.promise + }) + const controller = new AbortController() + const args = { apiKey: 'key', endpointId: 'fal-ai/flux' } + const cancelled = lookup({ ...args, requestId: 'cancelled', signal: controller.signal }) + const rejected = expect(cancelled).rejects.toThrow('Stopped') + const survivor = lookup({ ...args, requestId: 'survivor' }) + await started.promise + controller.abort(new Error('Stopped')) + await rejected + pending.resolve(jsonResponse({ total_cost: 0.2 })) + expect(await survivor).toMatchObject({ costDollars: 0.2 }) + expect(estimates).toBe(1) + + const lateResponse = createDeferred() + vi.stubGlobal('fetch', async (url: string | URL) => + String(url).includes('billing-events') + ? jsonResponse({ billing_events: [] }) + : lateResponse.promise + ) + const hung = lookup({ ...args, endpointId: 'fal-ai/hung', requestId: 'hung' }) + await vi.advanceTimersByTimeAsync(10001) + expect(await hung).toMatchObject({ source: 'fallback_floor' }) + vi.stubGlobal('fetch', async (url: string | URL) => + String(url).includes('billing-events') + ? jsonResponse({ billing_events: [] }) + : jsonResponse({ total_cost: 0.4 }) ) + expect( + await lookup({ ...args, endpointId: 'fal-ai/hung', requestId: 'recovered' }) + ).toMatchObject({ costDollars: 0.4 }) + lateResponse.resolve(jsonResponse({ total_cost: 0.9 })) + await vi.advanceTimersByTimeAsync(0) + expect( + await lookup({ ...args, endpointId: 'fal-ai/hung', requestId: 'after-late' }) + ).toMatchObject({ costDollars: 0.4 }) }) }) diff --git a/apps/sim/lib/tools/falai-pricing.ts b/apps/sim/lib/tools/falai-pricing.ts index 821f7169e8c..c5f2e54dd75 100644 --- a/apps/sim/lib/tools/falai-pricing.ts +++ b/apps/sim/lib/tools/falai-pricing.ts @@ -1,13 +1,13 @@ +import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { interruptibleSleep } from '@sim/utils/helpers' import { isRecordLike } from '@sim/utils/object' +import { parseRetryAfter } from '@sim/utils/retry' +import { LRUCache } from 'lru-cache' +import { coalesceLocally } from '@/lib/concurrency/singleflight' import { DeadlineExceededError, withinDeadline } from '@/lib/core/utils/deadline' -import { - DEFAULT_MAX_ERROR_BODY_BYTES, - readResponseJsonWithLimit, - readResponseTextWithLimit, -} from '@/lib/core/utils/stream-limits' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' export const FALAI_HOSTED_KEY_MARKUP_MULTIPLIER = 1.5 export const FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS = 0.05 @@ -18,10 +18,39 @@ const FALAI_BILLING_EVENT_RETRY_MS = 500 const FALAI_PRICING_RESPONSE_MAX_BYTES = 2 * 1024 * 1024 /** * Longest a cost lookup may take, covering every billing-event attempt and the estimate. The - * generation is already paid for by then, so a lookup that runs out falls back to the floor. + * queue is terminal by then; successful delivery can fall back to an explicitly labeled estimate. */ -const FALAI_COST_LOOKUP_TIMEOUT_MS = 10_000 +export const FALAI_COST_LOOKUP_TIMEOUT_MS = 10_000 const logger = createLogger('FalAIPricing') +const FALAI_ESTIMATE_TTL_MS = 5 * 60_000 +const FALAI_COOLDOWN_MS = 60_000 +const FALAI_MAX_COOLDOWN_MS = 5 * 60_000 +const estimateCache = new LRUCache({ max: 4096, ttl: FALAI_ESTIMATE_TTL_MS }) +const lookupCooldowns = new LRUCache({ max: 2048 }) + +function credentialFingerprint(apiKey: string): string { + return createHash('sha256').update(apiKey).digest('hex') +} + +function platformError(response: Response, cooldownKey: string, operation: string): string { + const error = `Fal.ai ${operation} failed: HTTP ${response.status}` + if (response.status === 429 || response.status === 401 || response.status === 403) { + const ttl = Math.max( + 1000, + lookupCooldowns.getRemainingTTL(cooldownKey), + parseRetryAfter(response.headers.get('retry-after'), FALAI_MAX_COOLDOWN_MS) ?? + FALAI_COOLDOWN_MS + ) + lookupCooldowns.set(cooldownKey, error, { ttl }) + logger.warn('Fal.ai cost lookup cooling down', { + operation, + status: response.status, + retryAfterMs: ttl, + }) + } + void response.body?.cancel().catch(() => undefined) + return error +} export interface FalAICostMetadata { endpointId: string @@ -85,7 +114,7 @@ function parseBillingEvent(value: unknown): FalAIBillingEvent | undefined { const costEstimateNanoUsd = getNumber(value.cost_estimate_nano_usd) if (typeof requestId !== 'string' || typeof endpointId !== 'string') return undefined - if (costEstimateNanoUsd === undefined) return undefined + if (costEstimateNanoUsd === undefined || costEstimateNanoUsd < 0) return undefined return { request_id: requestId, @@ -101,7 +130,9 @@ async function fetchFalAIBillingEvent( apiKey: string, requestId: string, signal?: AbortSignal -): Promise { +): Promise<{ event?: FalAIBillingEvent; retry: boolean }> { + const cooldownKey = `billing:${credentialFingerprint(apiKey)}` + if (lookupCooldowns.has(cooldownKey)) return { retry: false } const url = new URL('https://api.fal.ai/v1/models/billing-events') url.searchParams.set('request_id', requestId) url.searchParams.set('limit', '1') @@ -120,14 +151,18 @@ async function fetchFalAIBillingEvent( requestId, error: getErrorMessage(error, 'Unknown error'), }) - return undefined + return { retry: true } } - if (!response.ok) return undefined + if (!response.ok) { + platformError(response, cooldownKey, 'billing event lookup') + return { retry: response.status >= 500 } + } const data = await readResponseJsonWithLimit(response, { maxBytes: FALAI_PRICING_RESPONSE_MAX_BYTES, label: 'Fal.ai billing event response', + signal, }).catch((error) => { signal?.throwIfAborted() logger.warn('Failed to parse Fal.ai billing event response', { @@ -136,14 +171,18 @@ async function fetchFalAIBillingEvent( }) return undefined }) - if (!isRecordLike(data) || !Array.isArray(data.billing_events)) return undefined + if (!isRecordLike(data) || !Array.isArray(data.billing_events)) return { retry: true } - return data.billing_events.map(parseBillingEvent).find(Boolean) + const event = data.billing_events + .map(parseBillingEvent) + .find((entry) => entry?.request_id === requestId) + return { event, retry: true } } async function estimateFalAICallCost( apiKey: string, endpointId: string, + cooldownKey: string, signal?: AbortSignal ): Promise<{ costDollars?: number; error?: string }> { let response: Response @@ -170,11 +209,7 @@ async function estimateFalAICallCost( } if (!response.ok) { - const error = await readResponseTextWithLimit(response, { - maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, - label: 'Fal.ai pricing estimate error response', - }).catch(() => '') - return { error: `Fal.ai pricing estimate failed: ${response.status} ${error}` } + return { error: platformError(response, cooldownKey, 'pricing estimate') } } let data: unknown @@ -182,42 +217,85 @@ async function estimateFalAICallCost( data = await readResponseJsonWithLimit(response, { maxBytes: FALAI_PRICING_RESPONSE_MAX_BYTES, label: 'Fal.ai pricing estimate response', + signal, }) } catch (error) { signal?.throwIfAborted() return { error: getErrorMessage(error, 'Unknown error') } } const totalCost = isRecordLike(data) ? getNumber(data.total_cost) : undefined - if (totalCost === undefined) { + if (totalCost === undefined || totalCost < 0) { return { error: 'Fal.ai pricing estimate missing total_cost' } } return { costDollars: totalCost } } +/** Historical prices are account-specific; exact job receipts always bypass this process-local cache. */ +async function getHistoricalEstimate(apiKey: string, endpointId: string, signal?: AbortSignal) { + const fingerprint = credentialFingerprint(apiKey) + const cacheKey = `${fingerprint}:${endpointId}` + const cached = estimateCache.get(cacheKey) + if (cached !== undefined) return { costDollars: cached } + const cooldownKey = `estimate:${fingerprint}` + const cooldown = lookupCooldowns.get(cooldownKey) + if (cooldown) return { error: cooldown } + + // One caller stopping must not cancel the estimate shared by other completed jobs. + const deadlineAt = Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS + const estimate = await coalesceLocally(`fal-estimate:${cacheKey}`, async () => { + const result = await withinDeadline( + (producerSignal) => estimateFalAICallCost(apiKey, endpointId, cooldownKey, producerSignal), + deadlineAt + ) + if (result.costDollars !== undefined) estimateCache.set(cacheKey, result.costDollars) + return result + }) + signal?.throwIfAborted() + return estimate +} + interface FalAICostLookup { apiKey: string endpointId: string requestId: string /** Ends the lookup with an error. Without one, only {@link FALAI_COST_LOOKUP_TIMEOUT_MS} ends it. */ signal?: AbortSignal + deadlineAt?: number } /** * The cost of a completed fal request. The lookup is bounded on its own: past * {@link FALAI_COST_LOOKUP_TIMEOUT_MS} it returns the fallback floor rather than nothing. */ -export async function getFalAICostMetadata(lookup: FalAICostLookup): Promise { +export function getFalAICostMetadata(lookup: FalAICostLookup): Promise { + return withCostDeadline(lookup, lookUpFalAICost) +} + +/** Estimates a request whose exact receipt has already been checked, sharing that lookup's deadline. */ +export async function getFalAIEstimatedCostMetadata( + lookup: FalAICostLookup +): Promise { + lookup.signal?.throwIfAborted() + const cached = estimateCache.get(`${credentialFingerprint(lookup.apiKey)}:${lookup.endpointId}`) + if (cached !== undefined) return historicalCost(lookup, cached) + return withCostDeadline(lookup, lookUpEstimatedCost) +} + +async function withCostDeadline( + lookup: FalAICostLookup, + operation: (lookup: FalAICostLookup) => Promise +): Promise { const { endpointId, requestId } = lookup try { return await withinDeadline( - (signal) => lookUpFalAICost({ ...lookup, signal }), - Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, + (signal) => operation({ ...lookup, signal }), + lookup.deadlineAt ?? Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, lookup.signal ) } catch (error) { if (!(error instanceof DeadlineExceededError)) throw error - logger.warn('Fal.ai cost lookup timed out after generation completed', { + logger.warn('Fal.ai cost lookup timed out after queue completion', { endpointId, requestId, }) @@ -240,14 +318,33 @@ function fallbackFloorCost( } } -async function lookUpFalAICost({ +/** Reads only an exact receipt, without inventing a charge for a failed result. */ +export async function getFalAIBillingReceipt( + lookup: FalAICostLookup +): Promise { + try { + return await withinDeadline( + (signal) => lookUpBillingReceipt({ ...lookup, signal }), + lookup.deadlineAt ?? Date.now() + FALAI_COST_LOOKUP_TIMEOUT_MS, + lookup.signal + ) + } catch (error) { + if (!(error instanceof DeadlineExceededError)) throw error + logger.warn('Fal.ai billing receipt lookup timed out', { + endpointId: lookup.endpointId, + requestId: lookup.requestId, + }) + return undefined + } +} + +async function lookUpBillingReceipt({ apiKey, - endpointId, requestId, signal, -}: FalAICostLookup): Promise { +}: FalAICostLookup): Promise { for (let attempt = 0; attempt < FALAI_BILLING_EVENT_ATTEMPTS; attempt++) { - const event = await fetchFalAIBillingEvent(apiKey, requestId, signal) + const { event, retry } = await fetchFalAIBillingEvent(apiKey, requestId, signal) if (event) { return { endpointId: event.endpoint_id, @@ -261,24 +358,33 @@ async function lookUpFalAICost({ } } + if (!retry) break if (attempt < FALAI_BILLING_EVENT_ATTEMPTS - 1) { await interruptibleSleep(FALAI_BILLING_EVENT_RETRY_MS, signal) signal?.throwIfAborted() } } +} - const estimate = await estimateFalAICallCost(apiKey, endpointId, signal) - if (estimate.costDollars !== undefined) { - return { - endpointId, - requestId, - costDollars: estimate.costDollars, - source: 'historical_estimate', - currency: 'USD', - } - } +async function lookUpFalAICost(lookup: FalAICostLookup): Promise { + const receipt = await lookUpBillingReceipt(lookup) + if (receipt) return receipt + return lookUpEstimatedCost(lookup) +} + +function historicalCost( + { endpointId, requestId }: FalAICostLookup, + costDollars: number +): FalAICostMetadata { + return { endpointId, requestId, costDollars, source: 'historical_estimate', currency: 'USD' } +} + +async function lookUpEstimatedCost(lookup: FalAICostLookup): Promise { + const { apiKey, endpointId, requestId, signal } = lookup + const estimate = await getHistoricalEstimate(apiKey, endpointId, signal) + if (estimate.costDollars !== undefined) return historicalCost(lookup, estimate.costDollars) - logger.warn('Fal.ai cost metadata unavailable after generation completed', { + logger.warn('Fal.ai cost metadata unavailable after queue completion', { endpointId, requestId, error: estimate.error, diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 769db1dcf02..d1750fa56a8 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -182,7 +182,7 @@ export async function uploadFile(options: UploadFileOptions): Promise throw new Error('Upload cleanup requires a newly allocated execution or Copilot key') } - logger.info(`Uploading file to ${context} storage: ${fileName}`) + logger.debug(`Uploading file to ${context} storage: ${fileName}`) const config = getStorageConfig(context) diff --git a/apps/sim/lib/uploads/utils/file.server.ts b/apps/sim/lib/uploads/utils/file.server.ts index ce80eff8c88..a022afb1a05 100644 --- a/apps/sim/lib/uploads/utils/file.server.ts +++ b/apps/sim/lib/uploads/utils/file.server.ts @@ -116,7 +116,8 @@ export async function resolveFileInputToUrl( const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context) if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + logger.warn('Unauthorized presigned URL generation attempt', { + requestId, userId, key: userFile.key, context, @@ -189,10 +190,7 @@ export async function resolveFileInputToUrl( } } } else if (filePath.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: filePath.substring(0, 50), - }) + logger.warn('Invalid internal path', { requestId, userId, path: filePath.substring(0, 50) }) return { error: { status: 400, @@ -332,7 +330,8 @@ export async function resolveInternalFileUrl( const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context) if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + logger.warn('Unauthorized presigned URL generation attempt', { + requestId, userId, key: storageKey, context, @@ -345,10 +344,10 @@ export async function resolveInternalFileUrl( context, presignExpirySeconds ) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + logger.info(`Generated presigned URL for ${context} file`, { requestId }) return { fileUrl } } catch (error) { - logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + logger.error('Failed to generate presigned URL:', error, { requestId }) return { error: { status: 500, message: 'Failed to generate file access URL' } } } } @@ -382,12 +381,12 @@ export async function downloadFileFromStorage( assertKnownSizeWithinLimit(userFile.size, maxBytes, 'storage file download') if (isExecutionFile(userFile)) { - logger.info(`[${requestId}] Downloading from execution storage: ${userFile.key}`) + logger.info(`Downloading from execution storage: ${userFile.key}`, { requestId }) const { downloadExecutionFile } = await import('@/lib/uploads/contexts/execution/file-manager') buffer = await downloadExecutionFile(userFile, { maxBytes, signal }) } else if (userFile.key) { const context = resolveTrustedFileContext(userFile.key, userFile.context) - logger.info(`[${requestId}] Downloading from ${context} storage: ${userFile.key}`) + logger.info(`Downloading from ${context} storage: ${userFile.key}`, { requestId }) const { downloadFile } = await import('@/lib/uploads/core/storage-service') buffer = await downloadFile({ @@ -542,7 +541,7 @@ export async function downloadServableFilesWithinBudget( let spent = 0 for (const userFile of userFiles) { - logger.info(`[${requestId}] Downloading ${userFile.name} (${userFile.size} bytes)`) + logger.info(`Downloading ${userFile.name} (${userFile.size} bytes)`, { requestId }) let servable: ServableFile try { servable = await downloadServableFileFromStorage(userFile, requestId, logger, { diff --git a/apps/sim/lib/uploads/utils/file.ts b/apps/sim/lib/uploads/utils/file.ts index cf7bc616d84..d4470d06f35 100644 --- a/apps/sim/lib/uploads/utils/file.ts +++ b/apps/sim/lib/uploads/utils/file.ts @@ -994,7 +994,7 @@ function convertToUserFile(file: RawFileInput, requestId: string, logger: Logger base64: file.base64, } - logger.info(`[${requestId}] Converted file to UserFile: ${userFile.name} (key: ${userFile.key})`) + logger.info(`Converted file to UserFile: ${userFile.name} (key: ${userFile.key})`, { requestId }) return userFile } @@ -1009,14 +1009,14 @@ export function processSingleFileToUserFile( ): UserFile { if (Array.isArray(file)) { const errorMsg = `Expected a single file but received an array with ${file.length} file(s). Use a file input that accepts multiple files, or select a specific file from the array (e.g., {{block.files[0]}}).` - logger.error(`[${requestId}] ${errorMsg}`) + logger.error(errorMsg, { requestId }) throw new Error(errorMsg) } const userFile = convertToUserFile(file, requestId, logger) if (!userFile) { const errorMsg = `File has no storage key: ${file.name || 'unknown'}` - logger.warn(`[${requestId}] ${errorMsg}`) + logger.warn(errorMsg, { requestId }) throw new Error(errorMsg) } @@ -1036,7 +1036,7 @@ export function processFilesToUserFiles( for (const file of filesArray) { if (Array.isArray(file)) { - logger.warn(`[${requestId}] Skipping nested array in file input`) + logger.warn('Skipping nested array in file input', { requestId }) continue } @@ -1044,7 +1044,7 @@ export function processFilesToUserFiles( if (userFile) { userFiles.push(userFile) } else { - logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`) + logger.warn(`Skipping file without storage key: ${file.name || 'unknown'}`, { requestId }) } } diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.ts index 24a1509a4ec..f38a9391a23 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { isPlainRecord } from '@sim/utils/object' import { getRedisClient } from '@/lib/core/config/redis' import { getRedisBudgetKeys, getRedisBudgetLimits } from '@/lib/core/redis/byte-budget.server' +import { generateRequestId } from '@/lib/core/utils/request' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' import { @@ -214,10 +215,11 @@ function logSkippedCacheWrite( file: UserFile, error: ExecutionResourceLimitError ): void { - logger.warn( - `[${requestId ?? 'unknown'}] Skipping base64 cache write for ${file.name}: ${error.message}`, - { resource: error.resource, attemptedBytes: error.attemptedBytes } - ) + logger.warn(`Skipping base64 cache write for ${file.name}: ${error.message}`, { + requestId, + resource: error.resource, + attemptedBytes: error.attemptedBytes, + }) } function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Base64Cache { @@ -225,9 +227,9 @@ function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Bas const { executionId } = options if (!redis) { - logger.warn( - `[${options.requestId}] Redis unavailable for base64 cache, using in-memory fallback` - ) + logger.warn('Redis unavailable for base64 cache, using in-memory fallback', { + requestId: options.requestId, + }) return new InMemoryBase64Cache() } @@ -237,7 +239,7 @@ function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Bas const key = getFullCacheKey(executionId, file) return await redis.get(key) } catch (error) { - logger.warn(`[${options.requestId}] Redis get failed, skipping cache`, error) + logger.warn('Redis get failed, skipping cache', error, { requestId: options.requestId }) return null } }, @@ -302,7 +304,7 @@ function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Bas ) } } catch (error) { - logger.warn(`[${options.requestId}] Redis set failed, skipping cache`, error) + logger.warn('Redis set failed, skipping cache', error, { requestId: options.requestId }) } }, } @@ -410,7 +412,8 @@ async function resolveBase64( const base64Bytes = Buffer.byteLength(file.base64, 'base64') if (base64Bytes > maxBytes) { logger.warn( - `[${options.requestId}] Skipping existing base64 for ${file.name} (decoded ${base64Bytes} exceeds ${maxBytes})` + `Skipping existing base64 for ${file.name} (decoded ${base64Bytes} exceeds ${maxBytes})`, + { requestId: options.requestId } ) return { base64: null } } @@ -425,9 +428,9 @@ async function resolveBase64( Number.isFinite(file.size) && file.size > maxBytes ) { - logger.warn( - `[${options.requestId}] Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})` - ) + logger.warn(`Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})`, { + requestId: options.requestId, + }) return { base64: null } } @@ -436,11 +439,13 @@ async function resolveBase64( !allowUnknownSize && !hasStableStorageKey ) { - logger.warn(`[${options.requestId}] Skipping base64 for ${file.name} (unknown file size)`) + logger.warn(`Skipping base64 for ${file.name} (unknown file size)`, { + requestId: options.requestId, + }) return { base64: null } } - const requestId = options.requestId ?? 'unknown' + const requestId = options.requestId ?? generateRequestId() try { const result = await readUserFileContentWithContributors(file, { requestId, @@ -463,7 +468,7 @@ async function resolveBase64( if (error instanceof Error && error.name === 'DocCompileUserError') { throw error } - logger.warn(`[${requestId}] Failed to hydrate base64 for ${file.name}`, error) + logger.warn(`Failed to hydrate base64 for ${file.name}`, error, { requestId }) return { base64: null } } } @@ -489,7 +494,7 @@ async function hydrateUserFile( logger, }) } catch (error) { - logger.warn(`[${options.requestId ?? 'unknown'}] Skipping unauthorized file base64`, error) + logger.warn('Skipping unauthorized file base64', error, { requestId: options.requestId }) return stripBase64(file) } } diff --git a/apps/sim/lib/wand/strip-code-fences.ts b/apps/sim/lib/wand/strip-code-fences.ts index 64a346f1c8e..37bb69b4a65 100644 --- a/apps/sim/lib/wand/strip-code-fences.ts +++ b/apps/sim/lib/wand/strip-code-fences.ts @@ -20,6 +20,7 @@ const STRIPS_CODE_FENCES: Record = { 'json-schema': true, 'json-object': true, 'json-array': true, + 'json-value': true, 'table-schema': true, 'system-prompt': false, 'custom-tool-schema': true, diff --git a/apps/sim/lib/webhooks/attachment-processor.ts b/apps/sim/lib/webhooks/attachment-processor.ts index 0cbbf494ef6..d6895d45d55 100644 --- a/apps/sim/lib/webhooks/attachment-processor.ts +++ b/apps/sim/lib/webhooks/attachment-processor.ts @@ -36,7 +36,8 @@ export class WebhookAttachmentProcessor { } logger.info( - `[${executionContext.requestId}] Processing ${attachments.length} attachments for execution ${executionContext.executionId}` + `Processing ${attachments.length} attachments for execution ${executionContext.executionId}`, + { requestId: executionContext.requestId } ) const processedFiles: UserFile[] = [] @@ -49,16 +50,16 @@ export class WebhookAttachmentProcessor { ) processedFiles.push(userFile) } catch (error) { - logger.error( - `[${executionContext.requestId}] Error processing attachment '${attachment.name}':`, - error - ) + logger.error(`Error processing attachment '${attachment.name}':`, error, { + requestId: executionContext.requestId, + }) // Continue with other attachments rather than failing the entire request } } logger.info( - `[${executionContext.requestId}] Successfully processed ${processedFiles.length}/${attachments.length} attachments` + `Successfully processed ${processedFiles.length}/${attachments.length} attachments`, + { requestId: executionContext.requestId } ) return processedFiles diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 0cfb44e3290..275c353cc8f 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -497,10 +497,9 @@ export async function resolveWebhookConfigForBlock(input: { const { userId: botUserId } = await fetchSlackTeamId(botCredential.botToken) if (botUserId) providerConfig.bot_user_id = botUserId } catch (error: unknown) { - logger.error( - `[${input.requestId}] Slack custom bot identity resolution failed for ${input.block.id}`, - error - ) + logger.error(`Slack custom bot identity resolution failed for ${input.block.id}`, error, { + requestId: input.requestId, + }) return { success: false, error: { @@ -618,10 +617,9 @@ export async function resolveWebhookConfigForBlock(input: { routingKey = teamId if (botUserId) providerConfig.bot_user_id = botUserId } catch (error: unknown) { - logger.error( - `[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`, - error - ) + logger.error(`Slack team_id resolution failed for ${input.block.id}`, error, { + requestId: input.requestId, + }) return { success: false, error: { @@ -914,7 +912,8 @@ export async function saveTriggerWebhooksForDeploy({ webhooksByBlockId.set(wh.blockId, existingForBlock) } - logger.info(`[${requestId}] Starting webhook sync`, { + logger.info('Starting webhook sync', { + requestId, workflowId, currentBlockIds: Array.from(currentBlockIds), existingWebhookBlockIds: Array.from(webhooksByBlockId.keys()), @@ -944,7 +943,8 @@ export async function saveTriggerWebhooksForDeploy({ }) if (pathConflict) { logger.warn( - `[${requestId}] Webhook path conflict for "${triggerPath}": already owned by workflow ${pathConflict}` + `Webhook path conflict for "${triggerPath}": already owned by workflow ${pathConflict}`, + { requestId } ) return { success: false, @@ -966,9 +966,9 @@ export async function saveTriggerWebhooksForDeploy({ const [existingWh, ...extraWebhooks] = existingForBlock if (extraWebhooks.length > 0) { webhooksToDelete.push(...extraWebhooks) - logger.info( - `[${requestId}] Found ${extraWebhooks.length} extra webhook(s) for block ${block.id}` - ) + logger.info(`Found ${extraWebhooks.length} extra webhook(s) for block ${block.id}`, { + requestId, + }) } // Check if config changed or if we're forcing recreation (e.g., activating old version) @@ -987,11 +987,11 @@ export async function saveTriggerWebhooksForDeploy({ webhooksToDelete.push(existingWh) blocksNeedingWebhook.push(block) if (forceRecreateSubscriptions) { - logger.info( - `[${requestId}] Forcing webhook recreation for block ${block.id} (reactivating version)` - ) + logger.info(`Forcing webhook recreation for block ${block.id} (reactivating version)`, { + requestId, + }) } else { - logger.info(`[${requestId}] Webhook config changed for block ${block.id}, will recreate`) + logger.info(`Webhook config changed for block ${block.id}, will recreate`, { requestId }) } } if (!needsRecreation && existingConfig.subscriptionActivationPending === true) { @@ -1024,13 +1024,14 @@ export async function saveTriggerWebhooksForDeploy({ for (const wh of existingWebhooks) { if (wh.blockId && !currentBlockIds.has(wh.blockId)) { webhooksToDelete.push(wh) - logger.info(`[${requestId}] Webhook orphaned (block deleted): ${wh.blockId}`) + logger.info(`Webhook orphaned (block deleted): ${wh.blockId}`, { requestId }) } } // 3. Delete webhooks that need deletion if (webhooksToDelete.length > 0) { - logger.info(`[${requestId}] Deleting ${webhooksToDelete.length} webhook(s)`, { + logger.info(`Deleting ${webhooksToDelete.length} webhook(s)`, { + requestId, webhookIds: webhooksToDelete.map((wh) => wh.id), }) @@ -1042,7 +1043,7 @@ export async function saveTriggerWebhooksForDeploy({ }) cleanupSucceeded = true } catch (cleanupError) { - logger.warn(`[${requestId}] Failed to cleanup external webhook ${wh.id}`, cleanupError) + logger.warn(`Failed to cleanup external webhook ${wh.id}`, cleanupError, { requestId }) if (strictExternalCleanup) throw cleanupError } if (!strictExternalCleanup || cleanupSucceeded) { @@ -1106,11 +1107,12 @@ export async function saveTriggerWebhooksForDeploy({ }) } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) { - logger.warn(`[${requestId}] Rejected webhook trigger configuration for ${block.id}`, { + logger.warn(`Rejected webhook trigger configuration for ${block.id}`, { + requestId, error: error.message, }) } else { - logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error) + logger.error(`Failed to create external subscription for ${block.id}`, error, { requestId }) } await pendingVerificationTracker.clearAll() let cleanupFailure: unknown @@ -1131,8 +1133,9 @@ export async function saveTriggerWebhooksForDeploy({ } catch (cleanupError) { cleanupFailure = cleanupError logger.warn( - `[${requestId}] Failed to cleanup external subscription for ${sub.block.id}`, - cleanupError + `Failed to cleanup external subscription for ${sub.block.id}`, + cleanupError, + { requestId } ) await persistCreatedWebhookRecordAfterCleanupFailure({ workflowId, @@ -1215,10 +1218,9 @@ export async function saveTriggerWebhooksForDeploy({ } ) if (pollingError) { - logger.error( - `[${requestId}] Polling configuration failed for ${sub.block.id}`, - pollingError - ) + logger.error(`Polling configuration failed for ${sub.block.id}`, pollingError, { + requestId, + }) const cleanedWebhookIds: string[] = [] for (const otherSub of createdSubscriptions) { if (otherSub.webhookId === sub.webhookId) continue @@ -1238,8 +1240,9 @@ export async function saveTriggerWebhooksForDeploy({ cleanedWebhookIds.push(otherSub.webhookId) } catch (cleanupError) { logger.warn( - `[${requestId}] Failed to cleanup external subscription for ${otherSub.block.id}`, - cleanupError + `Failed to cleanup external subscription for ${otherSub.block.id}`, + cleanupError, + { requestId } ) } } else { @@ -1254,7 +1257,7 @@ export async function saveTriggerWebhooksForDeploy({ } } catch (error: unknown) { await pendingVerificationTracker.clearAll() - logger.error(`[${requestId}] Failed to save or activate webhook records`, error) + logger.error('Failed to save or activate webhook records', error, { requestId }) let cleanupFailure: unknown for (const sub of createdSubscriptions) { if (sub.externalSubscriptionCreated) { @@ -1277,10 +1280,9 @@ export async function saveTriggerWebhooksForDeploy({ if (recordsCommitted) await db.delete(webhook).where(eq(webhook.id, sub.webhookId)) } catch (cleanupError) { cleanupFailure = cleanupError - logger.warn( - `[${requestId}] Failed to cleanup external subscription for ${sub.block.id}`, - cleanupError - ) + logger.warn(`Failed to cleanup external subscription for ${sub.block.id}`, cleanupError, { + requestId, + }) if (!recordsCommitted) await persistCreatedWebhookRecordAfterCleanupFailure({ workflowId, @@ -1341,10 +1343,9 @@ async function persistCreatedWebhookRecordAfterCleanupFailure({ updatedAt: new Date(), }) } catch (persistError) { - logger.error( - `[${requestId}] Failed to persist webhook record after external cleanup failure`, - persistError - ) + logger.error('Failed to persist webhook record after external cleanup failure', persistError, { + requestId, + }) } } @@ -1391,12 +1392,8 @@ export async function cleanupWebhooksForWorkflow( } logger.info( - `[${requestId}] Cleaning up ${existingWebhooks.length} webhook(s) for ${skipExternalCleanup ? 'DB records only' : 'undeploy'}`, - { - workflowId, - deploymentVersionId, - webhookIds: existingWebhooks.map((wh) => wh.id), - } + `Cleaning up ${existingWebhooks.length} webhook(s) for ${skipExternalCleanup ? 'DB records only' : 'undeploy'}`, + { requestId, workflowId, deploymentVersionId, webhookIds: existingWebhooks.map((wh) => wh.id) } ) if (!skipExternalCleanup) { @@ -1420,7 +1417,8 @@ export async function cleanupWebhooksForWorkflow( shouldDeleteWebhook, }) if (!deleted) { - logger.info(`[${requestId}] Stopping webhook DB cleanup because deployment became active`, { + logger.info('Stopping webhook DB cleanup because deployment became active', { + requestId, workflowId, deploymentVersionId, webhookId: wh.id, @@ -1432,8 +1430,9 @@ export async function cleanupWebhooksForWorkflow( logger.info( deploymentVersionId - ? `[${requestId}] Cleaned up webhooks for workflow ${workflowId} deployment ${deploymentVersionId}` - : `[${requestId}] Cleaned up all webhooks for workflow ${workflowId}` + ? `Cleaned up webhooks for workflow ${workflowId} deployment ${deploymentVersionId}` + : `Cleaned up all webhooks for workflow ${workflowId}`, + { requestId } ) } @@ -1455,7 +1454,8 @@ async function cleanupWebhookRow(params: { const { webhook: wh, workflow, requestId, deploymentVersionId, strictExternalCleanup } = params const workflowId = wh.workflowId if (params.shouldDeleteWebhook && !(await params.shouldDeleteWebhook())) { - logger.info(`[${requestId}] Stopping webhook cleanup because deployment became active`, { + logger.info('Stopping webhook cleanup because deployment became active', { + requestId, workflowId, deploymentVersionId, webhookId: wh.id, @@ -1466,7 +1466,7 @@ async function cleanupWebhookRow(params: { try { await cleanupExternalWebhook(wh, workflow, requestId, { throwOnError: strictExternalCleanup }) } catch (cleanupError) { - logger.warn(`[${requestId}] Failed to cleanup external webhook ${wh.id}`, cleanupError) + logger.warn(`Failed to cleanup external webhook ${wh.id}`, cleanupError, { requestId }) if (strictExternalCleanup) throw cleanupError } @@ -1477,7 +1477,8 @@ async function cleanupWebhookRow(params: { shouldDeleteWebhook: params.shouldDeleteWebhook, }) if (!deleted) { - logger.info(`[${requestId}] Stopping webhook DB cleanup because deployment became active`, { + logger.info('Stopping webhook DB cleanup because deployment became active', { + requestId, workflowId, deploymentVersionId, webhookId: wh.id, @@ -1540,13 +1541,11 @@ export async function cleanupInactiveDeploymentWebhooks(params: { const batch = staleWebhooks.slice(0, params.limit) if (batch.length === 0) return { hasMore: false } - logger.info( - `[${requestId}] Cleaning up ${batch.length} webhook(s) owned by inactive deployments`, - { - workflowId, - webhookIds: batch.map((wh) => wh.id), - } - ) + logger.info(`Cleaning up ${batch.length} webhook(s) owned by inactive deployments`, { + requestId, + workflowId, + webhookIds: batch.map((wh) => wh.id), + }) for (const wh of batch) { const deploymentVersionId = wh.deploymentVersionId diff --git a/apps/sim/lib/webhooks/polling/gmail.ts b/apps/sim/lib/webhooks/polling/gmail.ts index 97514ef5646..7f8b75b7c88 100644 --- a/apps/sim/lib/webhooks/polling/gmail.ts +++ b/apps/sim/lib/webhooks/polling/gmail.ts @@ -77,12 +77,7 @@ export const gmailPollingHandler: PollingProviderHandler = { const config = getProviderConfig(webhookData.providerConfig) const now = new Date() - const { emails, latestHistoryId } = await fetchNewEmails( - accessToken, - config, - requestId, - logger - ) + const { emails, latestHistoryId } = await fetchNewEmails(accessToken, config, logger) const pollState = { lastCheckedTimestamp: now.toISOString(), // Omit unless this poll held a cursor, so it can't clear one an overlapping poll stored. @@ -92,11 +87,11 @@ export const gmailPollingHandler: PollingProviderHandler = { if (!emails || !emails.length) { await updateWebhookProviderConfig(webhookId, pollState, logger) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`) + logger.info(`No new emails found for webhook ${webhookId}`) return 'success' } - logger.info(`[${requestId}] Found ${emails.length} new emails for webhook ${webhookId}`) + logger.info(`Found ${emails.length} new emails for webhook ${webhookId}`) const { processedCount, failedCount } = await processEmails( emails, @@ -112,34 +107,27 @@ export const gmailPollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} emails failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} emails failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } - logger.error(`[${requestId}] Error processing Gmail webhook ${webhookId}:`, error) + logger.error(`Error processing Gmail webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } }, } -async function fetchNewEmails( - accessToken: string, - config: GmailWebhookConfig, - requestId: string, - logger: Logger -) { +async function fetchNewEmails(accessToken: string, config: GmailWebhookConfig, logger: Logger) { try { const useHistoryApi = !!config.historyId let emails: GmailEmail[] = [] @@ -165,13 +153,13 @@ async function fetchNewEmails( if (status === 404 || status === 400) { await historyResponse.body?.cancel().catch(() => {}) logger.info( - `[${requestId}] Gmail rejected history cursor ${config.historyId} (${status}); resyncing through search` + `Gmail rejected history cursor ${config.historyId} (${status}); resyncing through search` ) - return resyncAfterExpiredHistory(accessToken, config, requestId, logger) + return resyncAfterExpiredHistory(accessToken, config, logger) } const errorData = await historyResponse.json().catch(() => ({})) - logger.warn(`[${requestId}] Gmail history API error:`, { + logger.warn('Gmail history API error:', { status, statusText: historyResponse.statusText, error: errorData, @@ -183,8 +171,8 @@ async function fetchNewEmails( ) } - logger.info(`[${requestId}] Falling back to search API after history API error ${status}`) - return searchEmails(accessToken, config, requestId, logger) + logger.info(`Falling back to search API after history API error ${status}`) + return searchEmails(accessToken, config, logger) } const historyData = await historyResponse.json() @@ -212,14 +200,14 @@ async function fetchNewEmails( const sortedIds = [...messageIds].sort().reverse() const idsToFetch = sortedIds.slice(0, config.maxEmailsPerPoll || 25) - logger.info(`[${requestId}] Processing ${idsToFetch.length} emails from history API`) + logger.info(`Processing ${idsToFetch.length} emails from history API`) const emailResults = await Promise.allSettled( idsToFetch.map((messageId) => getEmailDetails(accessToken, messageId)) ) const rejected = emailResults.filter((r) => r.status === 'rejected') if (rejected.length > 0) { - logger.warn(`[${requestId}] Failed to fetch ${rejected.length} email details`) + logger.warn(`Failed to fetch ${rejected.length} email details`) } emails = emailResults .filter( @@ -229,13 +217,13 @@ async function fetchNewEmails( emails = filterEmailsByLabels(emails, config) } else { - return searchEmails(accessToken, config, requestId, logger) + return searchEmails(accessToken, config, logger) } return { emails, latestHistoryId } } catch (error) { const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error fetching new emails:`, errorMessage) + logger.error('Error fetching new emails:', errorMessage) throw error } } @@ -280,12 +268,7 @@ function buildGmailSearchQuery(config: { return baseQuery } -async function searchEmails( - accessToken: string, - config: GmailWebhookConfig, - requestId: string, - logger: Logger -) { +async function searchEmails(accessToken: string, config: GmailWebhookConfig, logger: Logger) { try { const baseQuery = buildGmailSearchQuery(config) let timeConstraint = '' @@ -320,7 +303,7 @@ async function searchEmails( if (!searchResponse.ok) { const errorData = await searchResponse.json() - logger.error(`[${requestId}] Gmail search API error:`, { + logger.error('Gmail search API error:', { status: searchResponse.status, statusText: searchResponse.statusText, query, @@ -334,7 +317,7 @@ async function searchEmails( const searchData = await searchResponse.json() if (!searchData.messages || !searchData.messages.length) { - logger.info(`[${requestId}] No emails found matching query: ${query}`) + logger.info(`No emails found matching query: ${query}`) return { emails: [], latestHistoryId: config.historyId } } @@ -342,7 +325,7 @@ async function searchEmails( let latestHistoryId = config.historyId logger.info( - `[${requestId}] Processing ${idsToFetch.length} emails from search API (total matches: ${searchData.messages.length})` + `Processing ${idsToFetch.length} emails from search API (total matches: ${searchData.messages.length})` ) const emailResults = await Promise.allSettled( @@ -350,7 +333,7 @@ async function searchEmails( ) const rejected = emailResults.filter((r) => r.status === 'rejected') if (rejected.length > 0) { - logger.warn(`[${requestId}] Failed to fetch ${rejected.length} email details`) + logger.warn(`Failed to fetch ${rejected.length} email details`) } const emails = emailResults .filter( @@ -365,7 +348,7 @@ async function searchEmails( return { emails, latestHistoryId } } catch (error) { const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error searching emails:`, errorMessage) + logger.error('Error searching emails:', errorMessage) throw error } } @@ -378,24 +361,22 @@ async function searchEmails( async function resyncAfterExpiredHistory( accessToken: string, config: GmailWebhookConfig, - requestId: string, logger: Logger ) { - const searchResult = await searchEmails(accessToken, config, requestId, logger) + const searchResult = await searchEmails(accessToken, config, logger) const candidate = searchResult.latestHistoryId !== config.historyId ? searchResult.latestHistoryId - : await getGmailProfileHistoryId(accessToken, requestId, logger) + : await getGmailProfileHistoryId(accessToken, logger) const latestHistoryId = candidate && candidate !== config.historyId ? candidate : undefined if (!latestHistoryId) { - logger.info(`[${requestId}] No replacement Gmail history cursor yet; polling through search`) + logger.info('No replacement Gmail history cursor yet; polling through search') } return { emails: searchResult.emails, latestHistoryId } } async function getGmailProfileHistoryId( accessToken: string, - requestId: string, logger: Logger ): Promise { try { @@ -403,15 +384,13 @@ async function getGmailProfileHistoryId( headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { - logger.warn( - `[${requestId}] Failed to fetch Gmail profile for fresh historyId: ${response.status}` - ) + logger.warn(`Failed to fetch Gmail profile for fresh historyId: ${response.status}`) return null } const profile = await response.json() return (profile.historyId as string | undefined) ?? null } catch (error) { - logger.warn(`[${requestId}] Error fetching Gmail profile:`, error) + logger.warn('Error fetching Gmail profile:', error) return null } } @@ -519,10 +498,7 @@ async function processEmails( const attachmentInfo = extractAttachmentInfo(payload) attachments = await downloadAttachments(email.id, attachmentInfo, accessToken) } catch (error) { - logger.error( - `[${requestId}] Error downloading attachments for email ${email.id}:`, - error - ) + logger.error(`Error downloading attachments for email ${email.id}:`, error) } } @@ -557,7 +533,7 @@ async function processEmails( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for email ${email.id}:`, + `Failed to process webhook for email ${email.id}:`, result.statusCode, result.error ) @@ -572,14 +548,12 @@ async function processEmails( } ) - logger.info( - `[${requestId}] Successfully processed email ${email.id} for webhook ${webhookData.id}` - ) + logger.info(`Successfully processed email ${email.id} for webhook ${webhookData.id}`) processedCount++ } catch (error) { if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error processing email ${email.id}:`, errorMessage) + logger.error(`Error processing email ${email.id}:`, errorMessage) failedCount++ } } diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index 0c468736386..72723f26cb4 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -119,7 +119,7 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { logger ) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] First poll for webhook ${webhookId}, seeded timestamp`) + logger.info(`First poll for webhook ${webhookId}, seeded timestamp`) return 'success' } @@ -128,11 +128,11 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { if (!events.length) { await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No changed events for webhook ${webhookId}`) + logger.info(`No changed events for webhook ${webhookId}`) return 'success' } - logger.info(`[${requestId}] Found ${events.length} changed events for webhook ${webhookId}`) + logger.info(`Found ${events.length} changed events for webhook ${webhookId}`) const { processedCount, failedCount, latestUpdated } = await processEvents( events, @@ -162,22 +162,20 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} events failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} events failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} events for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} events for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } - logger.error(`[${requestId}] Error processing Google Calendar webhook ${webhookId}:`, error) + logger.error(`Error processing Google Calendar webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -338,7 +336,7 @@ async function processEvents( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for event ${event.id}:`, + `Failed to process webhook for event ${event.id}:`, result.statusCode, result.error ) @@ -349,14 +347,12 @@ async function processEvents( } ) - logger.info( - `[${requestId}] Successfully processed event ${event.id} for webhook ${webhookData.id}` - ) + logger.info(`Successfully processed event ${event.id} for webhook ${webhookData.id}`) processedCount++ } catch (error) { if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error processing event ${event.id}:`, errorMessage) + logger.error(`Error processing event ${event.id}:`, errorMessage) failedCount++ } } diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index c73136273d6..cd98379d2f7 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -104,9 +104,7 @@ export const googleDrivePollingHandler: PollingProviderHandler = { logger ) await markWebhookSuccess(webhookId, logger) - logger.info( - `[${requestId}] First poll for webhook ${webhookId}, seeded pageToken: ${startPageToken}` - ) + logger.info(`First poll for webhook ${webhookId}, seeded pageToken: ${startPageToken}`) return 'success' } @@ -115,7 +113,7 @@ export const googleDrivePollingHandler: PollingProviderHandler = { if (!changes.length) { await updateWebhookProviderConfig(webhookId, { pageToken: newStartPageToken }, logger) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No changes found for webhook ${webhookId}`) + logger.info(`No changes found for webhook ${webhookId}`) return 'success' } @@ -125,14 +123,12 @@ export const googleDrivePollingHandler: PollingProviderHandler = { await updateWebhookProviderConfig(webhookId, { pageToken: newStartPageToken }, logger) await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] ${changes.length} changes found but none match filters for webhook ${webhookId}` + `${changes.length} changes found but none match filters for webhook ${webhookId}` ) return 'success' } - logger.info( - `[${requestId}] Found ${filteredChanges.length} matching changes for webhook ${webhookId}` - ) + logger.info(`Found ${filteredChanges.length} matching changes for webhook ${webhookId}`) const { processedCount, failedCount, newKnownFileIds } = await processChanges( filteredChanges, @@ -161,37 +157,33 @@ export const googleDrivePollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} changes failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} changes failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} changes for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} changes for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } if (error instanceof Error && error.name === 'DrivePageTokenInvalidError') { await updateWebhookProviderConfig(webhookId, { pageToken: undefined }, logger) await markWebhookSuccess(webhookId, logger) - logger.warn( - `[${requestId}] Drive page token invalid for webhook ${webhookId}, re-seeding on next poll` - ) + logger.warn(`Drive page token invalid for webhook ${webhookId}, re-seeding on next poll`) return 'success' } if (error instanceof Error && error.name === 'DriveRateLimitError') { await markWebhookSuccess(webhookId, logger) logger.warn( - `[${requestId}] Drive API rate limited for webhook ${webhookId}, skipping to retry next poll cycle` + `Drive API rate limited for webhook ${webhookId}, skipping to retry next poll cycle` ) return 'success' } - logger.error(`[${requestId}] Error processing Google Drive webhook ${webhookId}:`, error) + logger.error(`Error processing Google Drive webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -406,7 +398,7 @@ async function processChanges( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for file ${change.fileId}:`, + `Failed to process webhook for file ${change.fileId}:`, result.statusCode, result.error ) @@ -417,16 +409,13 @@ async function processChanges( }) logger.info( - `[${requestId}] Successfully processed change for file ${change.fileId} for webhook ${webhookData.id}` + `Successfully processed change for file ${change.fileId} for webhook ${webhookData.id}` ) processedCount++ } catch (error) { if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error( - `[${requestId}] Error processing change for file ${change.fileId}:`, - errorMessage - ) + logger.error(`Error processing change for file ${change.fileId}:`, errorMessage) failedCount++ } } diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index fb598c5846d..111858e41a8 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -73,7 +73,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { const now = new Date() if (!spreadsheetId || !sheetName) { - logger.error(`[${requestId}] Missing spreadsheetId or sheetName for webhook ${webhookId}`) + logger.error(`Missing spreadsheetId or sheetName for webhook ${webhookId}`) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -82,7 +82,6 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { accessToken, spreadsheetId, config.lastModifiedTime, - requestId, logger ) @@ -93,7 +92,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { logger ) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] Sheet not modified since last poll for webhook ${webhookId}`) + logger.info(`Sheet not modified since last poll for webhook ${webhookId}`) return 'success' } @@ -118,16 +117,14 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { logger ) await markWebhookSuccess(webhookId, logger) - logger.info( - `[${requestId}] First poll for webhook ${webhookId}, seeded row index: ${currentRowCount}` - ) + logger.info(`First poll for webhook ${webhookId}, seeded row index: ${currentRowCount}`) return 'success' } if (currentRowCount <= config.lastIndexChecked) { if (currentRowCount < config.lastIndexChecked) { logger.warn( - `[${requestId}] Row count decreased from ${config.lastIndexChecked} to ${currentRowCount} for webhook ${webhookId}` + `Row count decreased from ${config.lastIndexChecked} to ${currentRowCount} for webhook ${webhookId}` ) } await updateWebhookProviderConfig( @@ -140,7 +137,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { logger ) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new rows for webhook ${webhookId}`) + logger.info(`No new rows for webhook ${webhookId}`) return 'success' } @@ -156,7 +153,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { headerRowIndex > 0 ? Math.max(startRow, headerRowIndex + 1) : startRow logger.info( - `[${requestId}] Found ${newRowCount} new rows for webhook ${webhookId}, processing rows ${adjustedStartRow}-${endRow}` + `Found ${newRowCount} new rows for webhook ${webhookId}, processing rows ${adjustedStartRow}-${endRow}` ) // Entire batch is header/blank rows — advance pointer and skip fetch. @@ -175,7 +172,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { ) await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Batch ${startRow}-${endRow} contained only header/blank rows for webhook ${webhookId}, advancing pointer` + `Batch ${startRow}-${endRow} contained only header/blank rows for webhook ${webhookId}, advancing pointer` ) return 'success' } @@ -219,22 +216,20 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} rows failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} rows failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} rows for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} rows for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } - logger.error(`[${requestId}] Error processing Google Sheets webhook ${webhookId}:`, error) + logger.error(`Error processing Google Sheets webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -245,7 +240,6 @@ async function isDriveFileUnchanged( accessToken: string, spreadsheetId: string, lastModifiedTime: string | undefined, - requestId: string, logger: Logger ): Promise<{ unchanged: boolean; currentModifiedTime?: string }> { try { @@ -255,7 +249,7 @@ async function isDriveFileUnchanged( } return { unchanged: currentModifiedTime === lastModifiedTime, currentModifiedTime } } catch { - logger.warn(`[${requestId}] Drive modifiedTime check failed, proceeding with Sheets API`) + logger.warn('Drive modifiedTime check failed, proceeding with Sheets API') return { unchanged: false } } } @@ -395,7 +389,7 @@ async function processRows( // Skip empty rows — don't fire a workflow run with no data. if (!row || row.length === 0) { - logger.info(`[${requestId}] Skipping empty row ${rowNumber} for webhook ${webhookData.id}`) + logger.info(`Skipping empty row ${rowNumber} for webhook ${webhookData.id}`) continue } @@ -435,7 +429,7 @@ async function processRows( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for row ${rowNumber}:`, + `Failed to process webhook for row ${rowNumber}:`, result.statusCode, result.error ) @@ -446,14 +440,12 @@ async function processRows( } ) - logger.info( - `[${requestId}] Successfully processed row ${rowNumber} for webhook ${webhookData.id}` - ) + logger.info(`Successfully processed row ${rowNumber} for webhook ${webhookData.id}`) processedCount++ } catch (error) { if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error processing row ${rowNumber}:`, errorMessage) + logger.error(`Error processing row ${rowNumber}:`, errorMessage) failedCount++ } } diff --git a/apps/sim/lib/webhooks/polling/hubspot.ts b/apps/sim/lib/webhooks/polling/hubspot.ts index 58b6c012797..4eb2c5fe303 100644 --- a/apps/sim/lib/webhooks/polling/hubspot.ts +++ b/apps/sim/lib/webhooks/polling/hubspot.ts @@ -210,9 +210,9 @@ export const hubspotPollingHandler: PollingProviderHandler = { return await pollSearchBased(ctx, config, accessToken) } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } - logger.error(`[${requestId}] Error processing HubSpot webhook ${webhookId}:`, error) + logger.error(`Error processing HubSpot webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -265,7 +265,7 @@ async function pollSearchBased( ) await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Seeded HubSpot webhook ${webhookId} watermark to ${nowMs} (${objectType}/${eventType}/${filterProperty})` + `Seeded HubSpot webhook ${webhookId} watermark to ${nowMs} (${objectType}/${eventType}/${filterProperty})` ) return 'success' } @@ -277,7 +277,7 @@ async function pollSearchBased( ) } - const userFilters = buildUserFilters(config, logger, requestId) + const userFilters = buildUserFilters(config, logger) const properties = resolveRequestedProperties(config, objectType, filterProperty, userFilters) const maxRecords = Math.min( Math.max(config.maxRecordsPerPoll ?? DEFAULT_MAX_RECORDS, 1), @@ -294,7 +294,6 @@ async function pollSearchBased( properties, userFilters, maxRecords, - requestId, logger, }) @@ -305,12 +304,12 @@ async function pollSearchBased( logger ) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new HubSpot ${objectType} ${eventType} for webhook ${webhookId}`) + logger.info(`No new HubSpot ${objectType} ${eventType} for webhook ${webhookId}`) return 'success' } logger.info( - `[${requestId}] Found ${records.length} HubSpot ${objectType} ${eventType} candidates for webhook ${webhookId}` + `Found ${records.length} HubSpot ${objectType} ${eventType} candidates for webhook ${webhookId}` ) const targetProperty = config.targetPropertyName?.trim() || undefined @@ -348,15 +347,13 @@ async function pollSearchBased( if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} HubSpot records failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} HubSpot records failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Processed ${processedCount} HubSpot records${skippedCount ? `, skipped ${skippedCount} (no property change)` : ''}${failedCount ? `, ${failedCount} failed` : ''} for webhook ${webhookId}` + `Processed ${processedCount} HubSpot records${skippedCount ? `, skipped ${skippedCount} (no property change)` : ''}${failedCount ? `, ${failedCount} failed` : ''} for webhook ${webhookId}` ) return 'success' } @@ -391,7 +388,6 @@ async function pollListMembership( accessToken, initialAfter: config.lastSeenMembershipCursor?.trim() || undefined, pageLimit: seedComplete ? maxRecords : MAX_PAGES_PER_POLL * HUBSPOT_PAGE_LIMIT, - requestId, logger, }) @@ -403,12 +399,10 @@ async function pollListMembership( } if (result.reachedEnd) { update.membershipSeedComplete = true - logger.info( - `[${requestId}] HubSpot list_membership ${webhookId} seed complete (list=${listId})` - ) + logger.info(`HubSpot list_membership ${webhookId} seed complete (list=${listId})`) } else { logger.info( - `[${requestId}] HubSpot list_membership ${webhookId} seed in progress (list=${listId}, scanned ${result.scanned})` + `HubSpot list_membership ${webhookId} seed in progress (list=${listId}, scanned ${result.scanned})` ) } await updateWebhookProviderConfig(webhookId, update, logger) @@ -429,9 +423,7 @@ async function pollListMembership( return 'success' } - logger.info( - `[${requestId}] Found ${result.records.length} HubSpot list memberships for webhook ${webhookId}` - ) + logger.info(`Found ${result.records.length} HubSpot list memberships for webhook ${webhookId}`) let processedCount = 0 let failedCount = 0 @@ -470,7 +462,7 @@ async function pollListMembership( if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error failedCount++ logger.error( - `[${requestId}] Error processing HubSpot list membership ${member.recordId}:`, + `Error processing HubSpot list membership ${member.recordId}:`, getErrorMessage(error, 'Unknown error') ) } @@ -546,11 +538,7 @@ function resolveRequestedProperties( return [...requested] } -export function buildUserFilters( - config: HubSpotWebhookConfig, - logger?: Logger, - requestId?: string -): FilterClause[] { +export function buildUserFilters(config: HubSpotWebhookConfig, logger?: Logger): FilterClause[] { const filters: FilterClause[] = [] if (config.pipelineId?.trim()) { @@ -585,10 +573,9 @@ export function buildUserFilters( } } } catch (error) { - logger?.warn( - `[${requestId ?? ''}] Could not parse user filters as JSON, ignoring:`, - getErrorMessage(error, 'parse error') - ) + logger?.warn('Could not parse user filters as JSON, ignoring:', { + error: getErrorMessage(error, 'parse error'), + }) } } @@ -598,7 +585,7 @@ export function buildUserFilters( // the webhook is marked failed and the user can trim their filter configuration. if (filters.length > MAX_USER_FILTERS) { throw new Error( - `[${requestId ?? ''}] HubSpot webhook has ${filters.length} combined filters (pipeline/stage/owner shortcuts + advanced filters), exceeding the ${MAX_USER_FILTERS}-filter limit HubSpot's Search API allows alongside the reserved cursor filters. Reduce the number of filters.` + `HubSpot webhook has ${filters.length} combined filters (pipeline/stage/owner shortcuts + advanced filters), exceeding the ${MAX_USER_FILTERS}-filter limit HubSpot's Search API allows alongside the reserved cursor filters. Reduce the number of filters.` ) } @@ -638,7 +625,6 @@ interface FetchArgs { properties: string[] userFilters: FilterClause[] maxRecords: number - requestId: string logger: Logger } @@ -652,7 +638,6 @@ async function fetchHubSpotChanges(args: FetchArgs): Promise '') - logger.error(`[${requestId}] HubSpot search API error:`, { + logger.error('HubSpot search API error:', { status: response.status, objectType, error: errorBody, @@ -719,7 +704,7 @@ async function fetchHubSpotChanges(args: FetchArgs): Promise= maxRecords) break if (pages >= MAX_PAGES_PER_POLL) { logger.warn( - `[${requestId}] HubSpot poll hit MAX_PAGES_PER_POLL=${MAX_PAGES_PER_POLL} — remaining records will roll over to next poll` + `HubSpot poll hit MAX_PAGES_PER_POLL=${MAX_PAGES_PER_POLL} — remaining records will roll over to next poll` ) break } @@ -858,7 +843,7 @@ async function processRecords( failedCount++ cursorFrozen = true logger.error( - `[${requestId}] Error processing HubSpot ${objectType} ${record.id}:`, + `Error processing HubSpot ${objectType} ${record.id}:`, getErrorMessage(error, 'Unknown error') ) } @@ -900,7 +885,6 @@ interface FetchListMembershipPagesArgs { initialAfter: string | undefined /** Soft cap on emitted records (normal mode) or pages × HUBSPOT_PAGE_LIMIT (seed mode). */ pageLimit: number - requestId: string logger: Logger } @@ -927,7 +911,7 @@ interface FetchListMembershipPagesResult { async function fetchListMembershipPages( args: FetchListMembershipPagesArgs ): Promise { - const { listId, accessToken, initialAfter, pageLimit, requestId, logger } = args + const { listId, accessToken, initialAfter, pageLimit, logger } = args const records: ListMembershipRecord[] = [] let after: string | undefined = initialAfter @@ -943,9 +927,7 @@ async function fetchListMembershipPages( if (!response.ok) { const errorText = await response.text().catch(() => '') - logger.error( - `[${requestId}] HubSpot list memberships fetch failed ${response.status}: ${errorText}` - ) + logger.error(`HubSpot list memberships fetch failed ${response.status}: ${errorText}`) throw new Error( `HubSpot list memberships fetch ${response.status}: ${errorText.slice(0, 500)}` ) diff --git a/apps/sim/lib/webhooks/polling/imap.ts b/apps/sim/lib/webhooks/polling/imap.ts index e3383580e63..993fb9db798 100644 --- a/apps/sim/lib/webhooks/polling/imap.ts +++ b/apps/sim/lib/webhooks/polling/imap.ts @@ -125,7 +125,7 @@ export const imapPollingHandler: PollingProviderHandler = { const config = getProviderConfig(webhookData.providerConfig) if (!config.host || !config.username || !config.password) { - logger.error(`[${requestId}] Missing IMAP credentials for webhook ${webhookId}`) + logger.error(`Missing IMAP credentials for webhook ${webhookId}`) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -140,7 +140,7 @@ export const imapPollingHandler: PollingProviderHandler = { try { await client.connect() - const result = await fetchNewEmails(client, config, requestId, logger) + const result = await fetchNewEmails(client, config, logger) emails = result.emails latestUidByMailbox = result.latestUidByMailbox uidValidityByMailbox = result.uidValidityByMailbox @@ -157,12 +157,12 @@ export const imapPollingHandler: PollingProviderHandler = { uidValidityByMailbox ) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`) + logger.info(`No new emails found for webhook ${webhookId}`) await client.logout() return 'success' } - logger.info(`[${requestId}] Found ${emails.length} new emails for webhook ${webhookId}`) + logger.info(`Found ${emails.length} new emails for webhook ${webhookId}`) const { processedCount, failedCount } = await processEmails( emails, @@ -187,15 +187,13 @@ export const imapPollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} emails failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} emails failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (innerError) { @@ -206,9 +204,9 @@ export const imapPollingHandler: PollingProviderHandler = { } } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } - logger.error(`[${requestId}] Error processing IMAP webhook ${webhookId}`) + logger.error(`Error processing IMAP webhook ${webhookId}`) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -262,12 +260,7 @@ async function updateImapState( ) } -async function fetchNewEmails( - client: ImapFlow, - config: ImapWebhookConfig, - requestId: string, - logger: Logger -) { +async function fetchNewEmails(client: ImapFlow, config: ImapWebhookConfig, logger: Logger) { const emails: Array<{ uid: number mailboxPath: string @@ -294,7 +287,7 @@ async function fetchNewEmails( if (storedUidValidity && storedUidValidity !== currentUidValidity) { logger.warn( - `[${requestId}] UIDVALIDITY changed for ${mailboxPath} (${storedUidValidity} -> ${currentUidValidity}), discarding stored UID` + `UIDVALIDITY changed for ${mailboxPath} (${storedUidValidity} -> ${currentUidValidity}), discarding stored UID` ) delete latestUidByMailbox[mailboxPath] } @@ -308,7 +301,7 @@ async function fetchNewEmails( try { searchCriteria = JSON.parse(config.searchCriteria) } catch { - logger.warn(`[${requestId}] Invalid search criteria JSON, using default`) + logger.warn('Invalid search criteria JSON, using default') } } } @@ -360,7 +353,7 @@ async function fetchNewEmails( totalEmailsCollected++ } } catch { - logger.warn(`[${requestId}] Error processing mailbox ${mailboxPath}`) + logger.warn(`Error processing mailbox ${mailboxPath}`) } } @@ -583,7 +576,7 @@ async function processEmails( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for email ${email.uid}:`, + `Failed to process webhook for email ${email.uid}:`, result.statusCode, result.error ) @@ -602,7 +595,7 @@ async function processEmails( } await client.messageFlagsAdd(email.uid, ['\\Seen'], { uid: true }) } catch { - logger.warn(`[${requestId}] Failed to mark message ${email.uid} as read`) + logger.warn(`Failed to mark message ${email.uid} as read`) } } @@ -611,12 +604,12 @@ async function processEmails( ) logger.info( - `[${requestId}] Successfully processed email ${email.uid} from ${email.mailboxPath} for webhook ${webhookData.id}` + `Successfully processed email ${email.uid} from ${email.mailboxPath} for webhook ${webhookData.id}` ) processedCount++ } catch (error) { if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error - logger.error(`[${requestId}] Error processing email ${email.uid}`) + logger.error(`Error processing email ${email.uid}`) failedCount++ } } diff --git a/apps/sim/lib/webhooks/polling/orchestrator.ts b/apps/sim/lib/webhooks/polling/orchestrator.ts index 2e60ac387cc..af19b81ee52 100644 --- a/apps/sim/lib/webhooks/polling/orchestrator.ts +++ b/apps/sim/lib/webhooks/polling/orchestrator.ts @@ -47,13 +47,13 @@ export async function pollProvider(providerName: string): Promise { return 'skipped' } - const requestId = generateShortId() + const pollId = generateShortId() return withResourceOutboundScope({ workspaceId: entry.workflow.workspaceId }, () => handler.pollWebhook({ webhookData: entry.webhook, workflowData: entry.workflow, - requestId, - logger, + requestId: pollId, + logger: logger.withMetadata({ webhookId: entry.webhook.id, pollId }), }) ) }, diff --git a/apps/sim/lib/webhooks/polling/outlook.ts b/apps/sim/lib/webhooks/polling/outlook.ts index 99deb9b7d8b..cb350ef9d1a 100644 --- a/apps/sim/lib/webhooks/polling/outlook.ts +++ b/apps/sim/lib/webhooks/polling/outlook.ts @@ -119,13 +119,13 @@ export const outlookPollingHandler: PollingProviderHandler = { const webhookId = webhookData.id try { - logger.info(`[${requestId}] Processing Outlook webhook: ${webhookId}`) + logger.info(`Processing Outlook webhook: ${webhookId}`) const accessToken = await resolveOAuthCredential(webhookData, 'outlook', requestId) const config = getProviderConfig(webhookData.providerConfig) const now = new Date() - const { emails } = await fetchNewOutlookEmails(accessToken, config, requestId, logger) + const { emails } = await fetchNewOutlookEmails(accessToken, config, logger) if (!emails || !emails.length) { await updateWebhookProviderConfig( @@ -134,11 +134,11 @@ export const outlookPollingHandler: PollingProviderHandler = { logger ) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`) + logger.info(`No new emails found for webhook ${webhookId}`) return 'success' } - logger.info(`[${requestId}] Found ${emails.length} emails for webhook ${webhookId}`) + logger.info(`Found ${emails.length} emails for webhook ${webhookId}`) const { processedCount, failedCount } = await processOutlookEmails( emails, @@ -158,22 +158,20 @@ export const outlookPollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} emails failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} emails failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} emails for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { if (error instanceof PollAdmissionRefusedError) { - return skipAdmissionRefusedPoll(logger, requestId, webhookId) + return skipAdmissionRefusedPoll(logger, webhookId) } - logger.error(`[${requestId}] Error processing Outlook webhook ${webhookId}:`, error) + logger.error(`Error processing Outlook webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -189,7 +187,6 @@ const OUTLOOK_PAGE_SIZE = 50 async function fetchNewOutlookEmails( accessToken: string, config: OutlookWebhookConfig, - requestId: string, logger: Logger ) { try { @@ -211,7 +208,7 @@ async function fetchNewOutlookEmails( } const allEmails: OutlookEmail[] = [] let nextUrl: string | undefined = `${apiUrl}?${params.toString()}` - logger.info(`[${requestId}] Fetching emails from: ${nextUrl}`) + logger.info(`Fetching emails from: ${nextUrl}`) while (nextUrl && allEmails.length < maxEmails) { const response = await fetchWithRetry(nextUrl, { @@ -224,7 +221,7 @@ async function fetchNewOutlookEmails( const errorData = await response .json() .catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error:`, { + logger.error('Microsoft Graph API error:', { status: response.status, statusText: response.statusText, error: errorData, @@ -245,7 +242,7 @@ async function fetchNewOutlookEmails( if (pageEmails.length === 0) break } - logger.info(`[${requestId}] Fetched ${allEmails.length} emails total`) + logger.info(`Fetched ${allEmails.length} emails total`) const emails = allEmails @@ -254,15 +251,10 @@ async function fetchNewOutlookEmails( if (config.folderIds && config.folderIds.length > 0) { const wellKnownFolders = config.folderIds.filter(isWellKnownFolderName) if (wellKnownFolders.length > 0) { - resolvedFolderIds = await resolveWellKnownFolderIds( - accessToken, - config.folderIds, - requestId, - logger - ) + resolvedFolderIds = await resolveWellKnownFolderIds(accessToken, config.folderIds, logger) if (resolvedFolderIds.size < wellKnownFolders.length) { logger.warn( - `[${requestId}] Could not resolve all well-known folders (${resolvedFolderIds.size}/${wellKnownFolders.length}) — skipping folder filter to avoid incorrect results` + `Could not resolve all well-known folders (${resolvedFolderIds.size}/${wellKnownFolders.length}) — skipping folder filter to avoid incorrect results` ) skipFolderFilter = true } @@ -273,14 +265,12 @@ async function fetchNewOutlookEmails( ? emails : filterEmailsByFolder(emails, config, resolvedFolderIds) - logger.info( - `[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering` - ) + logger.info(`Fetched ${emails.length} emails, ${filteredEmails.length} after filtering`) return { emails: filteredEmails } } catch (error) { const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error fetching new Outlook emails:`, errorMessage) + logger.error('Error fetching new Outlook emails:', errorMessage) throw error } } @@ -302,7 +292,6 @@ function isWellKnownFolderName(folderId: string): boolean { async function resolveWellKnownFolderId( accessToken: string, folderName: string, - requestId: string, logger: Logger ): Promise { try { @@ -316,16 +305,14 @@ async function resolveWellKnownFolderId( ) if (!response.ok) { - logger.warn( - `[${requestId}] Failed to resolve well-known folder '${folderName}': ${response.status}` - ) + logger.warn(`Failed to resolve well-known folder '${folderName}': ${response.status}`) return null } const folder = await response.json() return folder.id || null } catch (error) { - logger.error(`[${requestId}] Error resolving well-known folder '${folderName}':`, error) + logger.error(`Error resolving well-known folder '${folderName}':`, error) return null } } @@ -333,7 +320,6 @@ async function resolveWellKnownFolderId( async function resolveWellKnownFolderIds( accessToken: string, folderIds: string[], - requestId: string, logger: Logger ): Promise> { const resolvedIds = new Map() @@ -342,7 +328,7 @@ async function resolveWellKnownFolderIds( const resolutions = await Promise.all( wellKnownFolders.map(async (folderName) => { - const actualId = await resolveWellKnownFolderId(accessToken, folderName, requestId, logger) + const actualId = await resolveWellKnownFolderId(accessToken, folderName, logger) return { folderName, actualId } }) ) @@ -353,9 +339,7 @@ async function resolveWellKnownFolderIds( } } - logger.info( - `[${requestId}] Resolved ${resolvedIds.size}/${wellKnownFolders.length} well-known folders` - ) + logger.info(`Resolved ${resolvedIds.size}/${wellKnownFolders.length} well-known folders`) return resolvedIds } @@ -404,17 +388,9 @@ async function processOutlookEmails( let attachments: OutlookAttachment[] = [] if (config.includeAttachments && email.hasAttachments) { try { - attachments = await downloadOutlookAttachments( - accessToken, - email.id, - requestId, - logger - ) + attachments = await downloadOutlookAttachments(accessToken, email.id, logger) } catch (error) { - logger.error( - `[${requestId}] Error downloading attachments for email ${email.id}:`, - error - ) + logger.error(`Error downloading attachments for email ${email.id}:`, error) } } @@ -452,7 +428,7 @@ async function processOutlookEmails( } logger.info( - `[${requestId}] Processing email: ${email.subject} from ${email.from?.emailAddress?.address}` + `Processing email: ${email.subject} from ${email.from?.emailAddress?.address}` ) const result = await processPolledWebhookEvent( @@ -465,7 +441,7 @@ async function processOutlookEmails( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for email ${email.id}:`, + `Failed to process webhook for email ${email.id}:`, result.statusCode, result.error ) @@ -480,13 +456,11 @@ async function processOutlookEmails( } ) - logger.info( - `[${requestId}] Successfully processed email ${email.id} for webhook ${webhookData.id}` - ) + logger.info(`Successfully processed email ${email.id} for webhook ${webhookData.id}`) processedCount++ } catch (error) { if (error instanceof PollAdmissionRefusedError && processedCount === 0) throw error - logger.error(`[${requestId}] Error processing email ${email.id}:`, error) + logger.error(`Error processing email ${email.id}:`, error) failedCount++ } } @@ -497,7 +471,6 @@ async function processOutlookEmails( async function downloadOutlookAttachments( accessToken: string, messageId: string, - requestId: string, logger: Logger ): Promise { const attachments: OutlookAttachment[] = [] @@ -513,7 +486,7 @@ async function downloadOutlookAttachments( ) if (!response.ok) { - logger.error(`[${requestId}] Failed to fetch attachments for message ${messageId}`) + logger.error(`Failed to fetch attachments for message ${messageId}`) return attachments } @@ -536,17 +509,15 @@ async function downloadOutlookAttachments( } } catch (error) { logger.error( - `[${requestId}] Error processing attachment ${attachment.id} for message ${messageId}:`, + `Error processing attachment ${attachment.id} for message ${messageId}:`, error ) } } - logger.info( - `[${requestId}] Downloaded ${attachments.length} attachments for message ${messageId}` - ) + logger.info(`Downloaded ${attachments.length} attachments for message ${messageId}`) } catch (error) { - logger.error(`[${requestId}] Error downloading attachments for message ${messageId}:`, error) + logger.error(`Error downloading attachments for message ${messageId}:`, error) } return attachments diff --git a/apps/sim/lib/webhooks/polling/rss.ts b/apps/sim/lib/webhooks/polling/rss.ts index 932ef9da5fb..5a4985a1c9c 100644 --- a/apps/sim/lib/webhooks/polling/rss.ts +++ b/apps/sim/lib/webhooks/polling/rss.ts @@ -106,7 +106,7 @@ export const rssPollingHandler: PollingProviderHandler = { const config = getProviderConfig(webhookData.providerConfig) if (!config?.feedUrl) { - logger.error(`[${requestId}] Missing feedUrl for webhook ${webhookId}`) + logger.error(`Missing feedUrl for webhook ${webhookId}`) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -117,16 +117,16 @@ export const rssPollingHandler: PollingProviderHandler = { items: newItems, etag, lastModified, - } = await fetchNewRssItems(config, webhookData.createdAt, requestId, logger) + } = await fetchNewRssItems(config, webhookData.createdAt, logger) if (!newItems.length) { await updateRssState(webhookId, now.toISOString(), [], config, logger, etag, lastModified) await markWebhookSuccess(webhookId, logger) - logger.info(`[${requestId}] No new items found for webhook ${webhookId}`) + logger.info(`No new items found for webhook ${webhookId}`) return 'success' } - logger.info(`[${requestId}] Found ${newItems.length} new items for webhook ${webhookId}`) + logger.info(`Found ${newItems.length} new items for webhook ${webhookId}`) const { processedCount, failedCount, admissionRejectedAt } = await processRssItems( newItems, @@ -148,7 +148,7 @@ export const rssPollingHandler: PollingProviderHandler = { await updateRssState(webhookId, now.toISOString(), newGuids, config, logger) } logger.info( - `[${requestId}] Stopped polling webhook ${webhookId}: execution admission refused, ${newItems.length - attemptedItems.length} items left for a later poll` + `Stopped polling webhook ${webhookId}: execution admission refused, ${newItems.length - attemptedItems.length} items left for a later poll` ) return 'skipped' } @@ -165,15 +165,13 @@ export const rssPollingHandler: PollingProviderHandler = { if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) - logger.warn( - `[${requestId}] All ${failedCount} items failed to process for webhook ${webhookId}` - ) + logger.warn(`All ${failedCount} items failed to process for webhook ${webhookId}`) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( - `[${requestId}] Successfully processed ${processedCount} items for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + `Successfully processed ${processedCount} items for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { @@ -181,7 +179,7 @@ export const rssPollingHandler: PollingProviderHandler = { webhookData, pollStartedAt, error, - `[${requestId}] Error polling RSS webhook ${webhookId}`, + `Error polling RSS webhook ${webhookId}`, logger ) return 'failure' @@ -223,7 +221,6 @@ async function updateRssState( async function fetchNewRssItems( config: RssWebhookConfig, subscriptionStartedAt: Date, - requestId: string, logger: Logger ): Promise<{ feed: RssFeed; items: RssItem[]; etag?: string; lastModified?: string }> { const urlValidation = await validateUrlWithDNS(config.feedUrl, 'feedUrl', 'requestTarget') @@ -250,7 +247,7 @@ async function fetchNewRssItems( }) if (response.status === 304) { - logger.info(`[${requestId}] RSS feed not modified (304) for ${config.feedUrl}`) + logger.info(`RSS feed not modified (304) for ${config.feedUrl}`) return { feed: { items: [] } as RssFeed, items: [], @@ -308,9 +305,7 @@ async function fetchNewRssItems( const limitedItems = newItems.slice(0, 25) - logger.info( - `[${requestId}] Found ${newItems.length} new items (processing ${limitedItems.length})` - ) + logger.info(`Found ${newItems.length} new items (processing ${limitedItems.length})`) return { feed: feed as RssFeed, @@ -336,9 +331,7 @@ async function processRssItems( const itemGuid = getRssItemGuid(item) if (!itemGuid) { - logger.warn( - `[${requestId}] Skipping RSS item with no identifiable GUID for webhook ${webhookData.id}` - ) + logger.warn(`Skipping RSS item with no identifiable GUID for webhook ${webhookData.id}`) continue } @@ -381,7 +374,7 @@ async function processRssItems( if (!result.success) { throwIfAdmissionRefused(result) logger.error( - `[${requestId}] Failed to process webhook for item ${itemGuid}:`, + `Failed to process webhook for item ${itemGuid}:`, result.statusCode, result.error ) @@ -393,7 +386,7 @@ async function processRssItems( ) logger.info( - `[${requestId}] Successfully processed item ${item.title || itemGuid} for webhook ${webhookData.id}` + `Successfully processed item ${item.title || itemGuid} for webhook ${webhookData.id}` ) processedCount++ } catch (error) { @@ -401,7 +394,7 @@ async function processRssItems( return { processedCount, failedCount, admissionRejectedAt: index } } const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error processing item:`, errorMessage) + logger.error('Error processing item:', errorMessage) failedCount++ } } diff --git a/apps/sim/lib/webhooks/polling/types.ts b/apps/sim/lib/webhooks/polling/types.ts index a208d279981..a3138141d79 100644 --- a/apps/sim/lib/webhooks/polling/types.ts +++ b/apps/sim/lib/webhooks/polling/types.ts @@ -18,7 +18,12 @@ export interface PollSummary { export interface PollWebhookContext { webhookData: WebhookRecord workflowData: WorkflowRecord + /** + * This poll's id, passed downstream as the request id of the executions it starts. + * `logger` already carries it as `pollId`; log lines never name it `requestId`. + */ requestId: string + /** Bound with `webhookId` and `pollId`. */ logger: Logger } diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 704739f63df..58d210971a1 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -63,14 +63,8 @@ export function throwIfAdmissionRefused(result: PolledWebhookEventResult): void } /** Logs a poll stopped by {@link PollAdmissionRefusedError} and reports it as skipped. */ -export function skipAdmissionRefusedPoll( - logger: Logger, - requestId: string, - webhookId: string -): 'skipped' { - logger.info( - `[${requestId}] Execution admission refused webhook ${webhookId}; left its items for a later poll` - ) +export function skipAdmissionRefusedPoll(logger: Logger, webhookId: string): 'skipped' { + logger.info(`Execution admission refused webhook ${webhookId}; left its items for a later poll`) return 'skipped' } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 55434ddfcf0..6dfd7a55199 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -143,15 +143,14 @@ export async function parseWebhookBody( } } catch (bodyError) { if (isPayloadSizeLimitError(bodyError)) { - logger.warn(`[${requestId}] Rejected oversized webhook body`, { + logger.warn('Rejected oversized webhook body', { + requestId, maxBytes: WEBHOOK_MAX_BODY_BYTES, observedBytes: bodyError.observedBytes, }) return new NextResponse('Request body too large', { status: 413 }) } - logger.error(`[${requestId}] Failed to read request body`, { - error: toError(bodyError).message, - }) + logger.error('Failed to read request body', { requestId, error: toError(bodyError).message }) return new NextResponse('Failed to read request body', { status: 400 }) } @@ -174,7 +173,8 @@ export async function parseWebhookBody( body = JSON.parse(rawBody) } } catch (parseError) { - logger.error(`[${requestId}] Failed to parse webhook body`, { + logger.error('Failed to parse webhook body', { + requestId, error: toError(parseError).message, contentType: request.headers.get('content-type'), bodyPreview: truncate(rawBody ?? '', 100), @@ -237,7 +237,8 @@ export async function handlePreLookupWebhookVerification( } logger.info( - `[${requestId}] Returning 200 for pending ${pendingVerification.provider} webhook verification on path: ${path}` + `Returning 200 for pending ${pendingVerification.provider} webhook verification on path: ${path}`, + { requestId } ) return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' }) @@ -350,7 +351,8 @@ export function handlePreDeploymentVerification( ): NextResponse | null { if (requiresPendingWebhookVerification(webhookRecord.provider)) { logger.info( - `[${requestId}] ${webhookRecord.provider} webhook - block not in deployment, returning 200 OK for URL validation` + `${webhookRecord.provider} webhook - block not in deployment, returning 200 OK for URL validation`, + { requestId } ) return NextResponse.json({ status: 'ok', @@ -411,7 +413,9 @@ export async function findAllWebhooksForPath( } if (results.length === 0) { - logger.debug(`[${options.requestId}] No active webhooks found for path: ${options.path}`) + logger.debug(`No active webhooks found for path: ${options.path}`, { + requestId: options.requestId, + }) return results } @@ -434,14 +438,17 @@ export async function findAllWebhooksForPath( const ownerResults = results.filter((result) => result.webhook.workflowId === ownerWorkflowId) logger.error( - `[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} (${claimOwnerWorkflowId === ownerWorkflowId ? 'path-claim owner' : 'earliest registration'}) and dropping ${results.length - ownerResults.length} foreign webhook(s).` + `Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} (${claimOwnerWorkflowId === ownerWorkflowId ? 'path-claim owner' : 'earliest registration'}) and dropping ${results.length - ownerResults.length} foreign webhook(s).`, + { requestId: options.requestId } ) return ownerResults } if (results.length > 1) { - logger.info(`[${options.requestId}] Found ${results.length} webhooks for path: ${options.path}`) + logger.info(`Found ${results.length} webhooks for path: ${options.path}`, { + requestId: options.requestId, + }) } return results @@ -517,7 +524,7 @@ export async function findWebhooksByRoutingKey( } if (results.length === 0) { - logger.warn(`[${requestId}] No active ${provider} webhooks for routing key`) + logger.warn(`No active ${provider} webhooks for routing key`, { requestId }) } return results @@ -583,9 +590,7 @@ export async function verifyProviderAuth( foundWorkflow.workspaceId ?? undefined ) } catch (error) { - logger.error(`[${requestId}] Failed to fetch environment variables`, { - error, - }) + logger.error('Failed to fetch environment variables', { requestId, error }) } } @@ -648,7 +653,8 @@ export async function checkWebhookPreprocessing( const error = preprocessResult.error const transientAdmissionFailure = classifyTransientAdmissionFailure(error) const admissionRejectionCode = getDeterministicAdmissionRejectionCode(error) - logger.warn(`[${requestId}] Webhook preprocessing failed`, { + logger.debug('Webhook preprocessing failed', { + requestId, provider: foundWebhook.provider, error: error.message, statusCode: error.statusCode, @@ -674,7 +680,7 @@ export async function checkWebhookPreprocessing( executionTimeoutMs: preprocessResult.executionTimeout.async, } } catch (preprocessError) { - logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError) + logger.error('Error during webhook preprocessing:', preprocessError, { requestId }) return { error: formatProviderErrorResponse(foundWebhook, 'Internal error during preprocessing', 500), @@ -742,7 +748,9 @@ async function queueWebhookExecutionWithResult( const billingAttribution = options.billingAttribution const workspaceId = foundWorkflow.workspaceId if (!actorUserId || !billingAttribution || !workspaceId) { - logger.error(`[${options.requestId}] Missing billing context for webhook ${foundWebhook.id}`) + logger.error(`Missing billing context for webhook ${foundWebhook.id}`, { + requestId: options.requestId, + }) return { outcome: 'failed', response: NextResponse.json( @@ -820,7 +828,8 @@ async function queueWebhookExecutionWithResult( }) reservationTransferred = true logger.info( - `[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue` + `Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue`, + { requestId: options.requestId } ) } else { const jobQueue = await getInlineJobQueue() @@ -836,9 +845,9 @@ async function queueWebhookExecutionWithResult( executeWebhookJob(payload, signal), }) reservationTransferred = true - logger.info( - `[${options.requestId}] Queued ${foundWebhook.provider} webhook execution ${jobId} via inline backend` - ) + logger.info(`Queued ${foundWebhook.provider} webhook execution ${jobId} via inline backend`, { + requestId: options.requestId, + }) } const successResponse = handler.formatSuccessResponse?.(providerConfig) ?? null @@ -852,7 +861,7 @@ async function queueWebhookExecutionWithResult( reason: 'queued', } } catch (error: unknown) { - logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error) + logger.error('Failed to queue webhook execution:', error, { requestId: options.requestId }) const errorResponse = handler.formatQueueErrorResponse?.() ?? null if (errorResponse) { @@ -1002,11 +1011,13 @@ export async function processPolledWebhookEvent( return { success: false, error: 'Webhook has no provider', statusCode: 400 } } const provider = foundWebhook.provider + // Concurrent polls in one tick share a request; the webhook id tells their lines apart. + const log = logger.withMetadata({ webhookId: foundWebhook.id }) const ticket = tryAdmit() if (!ticket) { const gateFailure = ADMISSION_ERROR_DESCRIPTOR.GATE_CAPACITY - logger.warn(`[${requestId}] Admission gate rejected polled webhook event`) + log.warn('Admission gate rejected polled webhook event', { requestId }) return { success: false, error: 'Server at capacity', @@ -1023,8 +1034,9 @@ export async function processPolledWebhookEvent( if (foundWebhook.blockId) { const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) if (!blockExists) { - logger.info( - `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}` + log.info( + `Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`, + { requestId } ) return { success: false, error: 'Trigger block not found in deployment', statusCode: 404 } } @@ -1042,7 +1054,8 @@ export async function processPolledWebhookEvent( typeof errorBody.error === 'string' ? errorBody.error : 'Preprocessing failed' - logger.warn(`[${requestId}] Polled webhook preprocessing failed`, { + log.warn('Polled webhook preprocessing failed', { + requestId, statusCode, error: errorMessage, }) @@ -1073,7 +1086,7 @@ export async function processPolledWebhookEvent( const billingAttribution = preprocessResult.billingAttribution const workspaceId = foundWorkflow.workspaceId ?? undefined if (!actorUserId || !billingAttribution || !workspaceId) { - logger.error(`[${requestId}] Missing billing context for webhook ${foundWebhook.id}`) + log.error(`Missing billing context for webhook ${foundWebhook.id}`, { requestId }) return { success: false, error: 'Unable to resolve billing account', statusCode: 500 } } @@ -1135,8 +1148,9 @@ export async function processPolledWebhookEvent( maxDurationSeconds, }) reservationTransferred = true - logger.info( - `[${requestId}] Queued polling webhook execution task ${jobId} for ${provider} webhook via job queue` + log.info( + `Queued polling webhook execution task ${jobId} for ${provider} webhook via job queue`, + { requestId } ) } else { const jobQueue = await getInlineJobQueue() @@ -1152,12 +1166,12 @@ export async function processPolledWebhookEvent( executeWebhookJob(payload, signal), }) reservationTransferred = true - logger.info(`[${requestId}] Queued ${provider} webhook execution ${jobId} via inline backend`) + log.info(`Queued ${provider} webhook execution ${jobId} via inline backend`, { requestId }) } return { success: true, executionId } } catch (error: unknown) { - logger.error(`[${requestId}] Failed to process polled webhook event:`, error) + log.error('Failed to process polled webhook event:', error, { requestId }) return { success: false, error: 'Internal server error', statusCode: 500 } } finally { if (!reservationTransferred && reservedExecutionId) { diff --git a/apps/sim/lib/webhooks/provider-subscription-utils.ts b/apps/sim/lib/webhooks/provider-subscription-utils.ts index 71b84f2c9f7..d3290eb56d0 100644 --- a/apps/sim/lib/webhooks/provider-subscription-utils.ts +++ b/apps/sim/lib/webhooks/provider-subscription-utils.ts @@ -31,7 +31,7 @@ export async function getCredentialOwner( ): Promise<{ userId: string; accountId: string } | null> { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { - logger.warn(`[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}`) + logger.warn(`Failed to resolve OAuth account for credentialId ${credentialId}`, { requestId }) return null } const [credentialRecord] = await db @@ -41,7 +41,7 @@ export async function getCredentialOwner( .limit(1) if (!credentialRecord?.userId) { - logger.warn(`[${requestId}] Credential owner not found for credentialId ${credentialId}`) + logger.warn(`Credential owner not found for credentialId ${credentialId}`, { requestId }) return null } diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 82ba65ca77c..61f172c6f18 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -337,7 +337,8 @@ export async function cleanupExternalWebhook( ) } catch (error) { const projected = projectProviderFailure(error, secrets, resolvedProviderConfig) - logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, { + logger.warn('Error cleaning up external webhook (non-fatal)', { + requestId, provider, webhookId: webhook.id, error: projected.message, diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 0c9a2ccbc03..6d7b88985dc 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -72,7 +72,8 @@ async function fetchAndProcessAirtablePayloads( if (!baseId || !airtableWebhookId) { logger.error( - `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` + `Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.`, + { requestId } ) return } @@ -80,16 +81,17 @@ async function fetchAndProcessAirtablePayloads( const credentialId = localProviderConfig.credentialId as string | undefined if (!credentialId) { logger.error( - `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` + `Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.`, + { requestId } ) return } const resolvedAirtable = await resolveOAuthAccountId(credentialId) if (!resolvedAirtable) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` - ) + logger.error(`Could not resolve credential ${credentialId} for Airtable webhook`, { + requestId, + }) return } @@ -107,7 +109,8 @@ async function fetchAndProcessAirtablePayloads( if (!ownerUserId) { logger.error( - `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` + `Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}`, + { requestId } ) return } @@ -116,7 +119,8 @@ async function fetchAndProcessAirtablePayloads( if (storedCursor === undefined || storedCursor === null) { logger.info( - `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` + `No cursor found in providerConfig for webhook ${webhookData.id}, initializing...`, + { requestId } ) localProviderConfig.externalWebhookCursor = null @@ -133,10 +137,11 @@ async function fetchAndProcessAirtablePayloads( .where(eq(webhook.id, webhookData.id as string)) localProviderConfig.externalWebhookCursor = null - logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) + logger.info(`Successfully initialized cursor for webhook ${webhookData.id}`, { requestId }) } catch (initError: unknown) { const err = initError as Error - logger.error(`[${requestId}] Failed to initialize cursor in DB`, { + logger.error('Failed to initialize cursor in DB', { + requestId, webhookId: webhookData.id, error: err.message, stack: err.stack, @@ -159,20 +164,19 @@ async function fetchAndProcessAirtablePayloads( ) if (!accessToken) { logger.error( - `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` + `Failed to obtain valid Airtable access token via credential ${credentialId}.`, + { requestId } ) throw new Error('Airtable access token not found.') } } catch (tokenError: unknown) { const err = tokenError as Error - logger.error( - `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, - { - error: err.message, - stack: err.stack, - credentialId, - } - ) + logger.error(`Failed to get Airtable OAuth token for credential ${credentialId}`, { + requestId, + error: err.message, + stack: err.stack, + credentialId, + }) return } @@ -209,14 +213,12 @@ async function fetchAndProcessAirtablePayloads( responseBody.error?.message || responseBody.error || `Airtable API error Status ${response.status}` - logger.error( - `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, - { - webhookId: webhookData.id, - status: response.status, - error: errorMessage, - } - ) + logger.error(`Airtable API request to /payloads failed (Call ${apiCallCount})`, { + requestId, + webhookId: webhookData.id, + status: response.status, + error: errorMessage, + }) // Error logging handled by logging session mightHaveMore = false break @@ -322,7 +324,8 @@ async function fetchAndProcessAirtablePayloads( localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too } catch (dbError: unknown) { const err = dbError as Error - logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { + logger.error('Failed to persist Airtable cursor to DB', { + requestId, webhookId: webhookData.id, cursor: currentCursor, error: err.message, @@ -332,7 +335,8 @@ async function fetchAndProcessAirtablePayloads( throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly } } else if (!nextCursor || typeof nextCursor !== 'number') { - logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { + logger.warn('Invalid or missing cursor received, stopping poll', { + requestId, webhookId: webhookData.id, apiCall: apiCallCount, receivedCursor: nextCursor, @@ -343,8 +347,9 @@ async function fetchAndProcessAirtablePayloads( } } catch (fetchError: unknown) { logger.error( - `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, - fetchError + `Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, + fetchError, + { requestId } ) // Error logging handled by logging session mightHaveMore = false @@ -354,7 +359,8 @@ async function fetchAndProcessAirtablePayloads( // Convert map values to array for final processing const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) logger.info( - `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` + `Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls`, + { requestId } ) if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { @@ -378,8 +384,9 @@ async function fetchAndProcessAirtablePayloads( // CRITICAL EXECUTION TRACE POINT logger.info( - `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, + `CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, { + requestId, workflowId: workflowData.id, recordCount: finalConsolidatedChanges.length, timestamp: new Date().toISOString(), @@ -388,7 +395,8 @@ async function fetchAndProcessAirtablePayloads( ) // Return the processed input for the trigger.dev task to handle - logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { + logger.info('CRITICAL_TRACE: Airtable changes processed, returning input', { + requestId, workflowId: workflowData.id, recordCount: finalConsolidatedChanges.length, rawPayloadCount: allPayloads.length, @@ -398,7 +406,8 @@ async function fetchAndProcessAirtablePayloads( return input } catch (processingError: unknown) { const err = processingError as Error - logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { + logger.error('CRITICAL_TRACE: Error processing Airtable changes', { + requestId, workflowId: workflowData.id, error: err.message, stack: err.stack, @@ -409,7 +418,8 @@ async function fetchAndProcessAirtablePayloads( } } else { // DEBUG: Log when no changes are found - logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { + logger.info('TRACE: No Airtable changes to process', { + requestId, workflowId: workflowData.id, apiCallCount, webhookId: webhookData.id, @@ -417,14 +427,12 @@ async function fetchAndProcessAirtablePayloads( } } catch (error) { // Catch any unexpected errors during the setup/polling logic itself - logger.error( - `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, - { - webhookId: webhookData.id, - workflowId: workflowData.id, - error: (error as Error).message, - } - ) + logger.error('Unexpected error during asynchronous Airtable payload processing task', { + requestId, + webhookId: webhookData.id, + workflowId: workflowData.id, + error: (error as Error).message, + }) // Error logging handled by logging session } } @@ -445,7 +453,8 @@ export const airtableHandler: WebhookProviderHandler = { const tableId = toStringOrNull(config.tableId) if (!baseId || !tableId) { - logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, { + logger.warn('Missing baseId or tableId for Airtable webhook creation.', { + requestId, webhookId: webhookRecord.id, }) throw new WebhookDeploymentConfigurationError( @@ -468,7 +477,8 @@ export const airtableHandler: WebhookProviderHandler = { : await getOAuthToken(userId, 'airtable') if (!accessToken) { logger.warn( - `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` + `Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`, + { requestId } ) throw new Error( 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' @@ -515,8 +525,8 @@ export const airtableHandler: WebhookProviderHandler = { responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' const errorType = responseBody.error?.type logger.error( - `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookRecord.id}. Status: ${airtableResponse.status}`, - { type: errorType, message: errorMessage, response: responseBody } + `Failed to create webhook in Airtable for webhook ${webhookRecord.id}. Status: ${airtableResponse.status}`, + { requestId, type: errorType, message: errorMessage, response: responseBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' @@ -529,23 +539,19 @@ export const airtableHandler: WebhookProviderHandler = { throw new Error(userFriendlyMessage) } - logger.info( - `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookRecord.id}.`, - { - airtableWebhookId: responseBody.id, - } - ) + logger.info(`Successfully created webhook in Airtable for webhook ${webhookRecord.id}.`, { + requestId, + airtableWebhookId: responseBody.id, + }) return { providerConfigUpdates: { externalId: responseBody.id } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookRecord.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Airtable webhook creation for webhook ${webhookRecord.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -563,7 +569,8 @@ export const airtableHandler: WebhookProviderHandler = { } if (!baseId) { - logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { + logger.warn('Missing baseId for Airtable webhook deletion', { + requestId, webhookId: webhookRecord.id, }) if (strict) throw new Error('Missing Airtable baseId for webhook deletion') @@ -572,7 +579,8 @@ export const airtableHandler: WebhookProviderHandler = { const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') if (!baseIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { + logger.warn('Invalid Airtable base ID format, skipping deletion', { + requestId, webhookId: webhookRecord.id, baseId: baseId.substring(0, 20), }) @@ -582,17 +590,18 @@ export const airtableHandler: WebhookProviderHandler = { const credentialId = config.credentialId as string | undefined if (!credentialId) { - logger.warn( - `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhookRecord.id}` - ) + logger.warn(`Missing credentialId for Airtable webhook deletion ${webhookRecord.id}`, { + requestId, + }) if (strict) throw new Error('Missing Airtable credentialId for webhook deletion') return } const accessToken = await getCredentialAccessToken(credentialId, requestId) if (!accessToken) { - const message = `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.` - logger.warn(message, { webhookId: webhookRecord.id }) + const message = + 'Could not retrieve Airtable access token. Cannot delete webhook in Airtable.' + logger.warn(message, { requestId, webhookId: webhookRecord.id }) if (strict) throw new Error(message) return } @@ -602,8 +611,8 @@ export const airtableHandler: WebhookProviderHandler = { if (!resolvedExternalId && strict) { logger.warn( - `[${requestId}] Missing Airtable externalId during strict cleanup; skipping unsafe URL-based remote deletion`, - { webhookId: webhookRecord.id, baseId } + 'Missing Airtable externalId during strict cleanup; skipping unsafe URL-based remote deletion', + { requestId, webhookId: webhookRecord.id, baseId } ) throw new Error('Missing Airtable externalId for strict cleanup') } @@ -631,19 +640,22 @@ export const airtableHandler: WebhookProviderHandler = { }) if (match?.id) { resolvedExternalId = match.id as string - logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { + logger.info('Resolved Airtable externalId by listing webhooks', { + requestId, baseId, externalId: resolvedExternalId, }) } else { - logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { + logger.warn('Could not resolve Airtable externalId from list', { + requestId, baseId, expectedNotificationUrl, }) } } else { externalIdLookupFailed = true - logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, { + logger.warn('Failed to list Airtable webhooks to resolve externalId', { + requestId, baseId, status: listResp.status, body: listBody, @@ -651,14 +663,16 @@ export const airtableHandler: WebhookProviderHandler = { } } catch (e: unknown) { externalIdLookupFailed = true - logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { + logger.warn('Error attempting to resolve Airtable externalId', { + requestId, error: (e as Error)?.message, }) } } if (!resolvedExternalId) { - logger.info(`[${requestId}] Airtable externalId not found; skipping remote deletion`, { + logger.info('Airtable externalId not found; skipping remote deletion', { + requestId, baseId, confirmedAbsent: !externalIdLookupFailed, }) @@ -670,7 +684,8 @@ export const airtableHandler: WebhookProviderHandler = { const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') if (!webhookIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { + logger.warn('Invalid Airtable webhook ID format, skipping deletion', { + requestId, webhookId: webhookRecord.id, externalId: resolvedExternalId.substring(0, 20), }) @@ -693,12 +708,13 @@ export const airtableHandler: WebhookProviderHandler = { } catch {} logger.warn( - `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, - { baseId, externalId: resolvedExternalId, response: responseBody } + `Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, + { requestId, baseId, externalId: resolvedExternalId, response: responseBody } ) if (strict) throw new Error(`Failed to delete Airtable webhook: ${airtableResponse.status}`) } else { - logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { + logger.info('Successfully deleted Airtable webhook in Airtable', { + requestId, baseId, externalId: resolvedExternalId, alreadyDeleted: airtableResponse.status === 404, @@ -706,7 +722,8 @@ export const airtableHandler: WebhookProviderHandler = { } } catch (error: unknown) { const err = error as Error - logger.error(`[${requestId}] Error deleting Airtable webhook`, { + logger.error('Error deleting Airtable webhook', { + requestId, webhookId: webhookRecord.id, error: err.message, stack: err.stack, @@ -724,7 +741,7 @@ export const airtableHandler: WebhookProviderHandler = { }, async formatInput({ webhook, workflow, requestId }: FormatInputContext) { - logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) + logger.info('Processing Airtable webhook via fetchAndProcessAirtablePayloads', { requestId }) const webhookData = { id: webhook.id, @@ -744,11 +761,11 @@ export const airtableHandler: WebhookProviderHandler = { ) if (airtableInput) { - logger.info(`[${requestId}] Executing workflow with Airtable changes`) + logger.info('Executing workflow with Airtable changes', { requestId }) return { input: airtableInput } } - logger.info(`[${requestId}] No Airtable changes to process`) + logger.info('No Airtable changes to process', { requestId }) return { input: null, skip: { message: 'No Airtable changes to process' } } }, } diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index c42c042414f..10e3a6bb066 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -165,9 +165,9 @@ export const ashbyHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { const secretToken = (providerConfig.secretToken as string | undefined)?.trim() if (!secretToken) { - logger.warn( - `[${requestId}] Ashby webhook missing secretToken in providerConfig — rejecting request` - ) + logger.warn('Ashby webhook missing secretToken in providerConfig — rejecting request', { + requestId, + }) return new NextResponse( 'Unauthorized - Ashby webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.', { status: 401 } @@ -176,12 +176,13 @@ export const ashbyHandler: WebhookProviderHandler = { const signature = request.headers.get('ashby-signature') if (!signature) { - logger.warn(`[${requestId}] Ashby webhook missing signature header`) + logger.warn('Ashby webhook missing signature header', { requestId }) return new NextResponse('Unauthorized - Missing Ashby signature', { status: 401 }) } if (!validateAshbySignature(secretToken, signature, rawBody)) { - logger.warn(`[${requestId}] Ashby signature verification failed`, { + logger.warn('Ashby signature verification failed', { + requestId, signatureLength: signature.length, secretLength: secretToken.length, }) @@ -202,7 +203,8 @@ export const ashbyHandler: WebhookProviderHandler = { const action = typeof obj?.action === 'string' ? obj.action : '' if (action === 'ping') { - logger.debug(`[${requestId}] Ashby ping event received. Skipping execution.`, { + logger.debug('Ashby ping event received. Skipping execution.', { + requestId, webhookId: webhook.id, triggerId, }) @@ -214,12 +216,8 @@ export const ashbyHandler: WebhookProviderHandler = { const { isAshbyEventMatch } = await import('@/triggers/ashby/utils') if (!isAshbyEventMatch(triggerId, action)) { logger.debug( - `[${requestId}] Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`, - { - webhookId: webhook.id, - triggerId, - receivedAction: action, - } + `Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`, + { requestId, webhookId: webhook.id, triggerId, receivedAction: action } ) return false } @@ -258,7 +256,8 @@ export const ashbyHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(ctx.webhook) const authString = Buffer.from(`${apiKey}:`).toString('base64') - logger.info(`[${ctx.requestId}] Creating Ashby webhook`, { + logger.info('Creating Ashby webhook', { + requestId: ctx.requestId, triggerId, webhookType, webhookId: ctx.webhook.id, @@ -321,19 +320,18 @@ export const ashbyHandler: WebhookProviderHandler = { } logger.info( - `[${ctx.requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${ctx.webhook.id}` + `Successfully created Ashby webhook subscription ${externalId} for webhook ${ctx.webhook.id}`, + { requestId: ctx.requestId } ) return { providerConfigUpdates: { externalId, secretToken } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${ctx.requestId}] Exception during Ashby webhook creation for webhook ${ctx.webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Ashby webhook creation for webhook ${ctx.webhook.id}.`, { + requestId: ctx.requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -346,7 +344,8 @@ export const ashbyHandler: WebhookProviderHandler = { if (!apiKey) { logger.warn( - `[${ctx.requestId}] Missing apiKey for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing apiKey for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Ashby apiKey for webhook deletion') return @@ -354,7 +353,8 @@ export const ashbyHandler: WebhookProviderHandler = { if (!externalId) { logger.warn( - `[${ctx.requestId}] Missing externalId for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing externalId for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Ashby externalId for webhook deletion') return @@ -393,27 +393,28 @@ export const ashbyHandler: WebhookProviderHandler = { const errorMessage = ashbyErrorMessage(responseBody, `HTTP ${ashbyResponse.status}`) if (!rejected) { - logger.info( - `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` - ) + logger.info(`Successfully deleted Ashby webhook subscription ${externalId}`, { + requestId: ctx.requestId, + }) } else if ( ashbyResponse.status === 404 || isAshbyWebhookNotFound(responseBody, errorMessage) ) { - logger.info( - `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` - ) + logger.info(`Ashby webhook ${externalId} not found during deletion (already removed)`, { + requestId: ctx.requestId, + }) } else { - logger.warn( - `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${errorMessage}`, - { status: ashbyResponse.status, response: responseBody } - ) + logger.warn(`Failed to delete Ashby webhook (non-fatal): ${errorMessage}`, { + requestId: ctx.requestId, + status: ashbyResponse.status, + response: responseBody, + }) if (ctx.strict) { throw new Error(`Failed to delete Ashby webhook: ${errorMessage}`) } } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error) + logger.warn('Error deleting Ashby webhook (non-fatal)', error, { requestId: ctx.requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 5b3fb473ce8..83f67aa0b54 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -47,13 +47,14 @@ export const attioHandler: WebhookProviderHandler = { if (!secret) { logger.debug( - `[${requestId}] Attio webhook ${webhook.id as string} has no signing secret, skipping signature verification` + `Attio webhook ${webhook.id as string} has no signing secret, skipping signature verification`, + { requestId } ) } else { const signature = request.headers.get('Attio-Signature') if (!signature) { - logger.warn(`[${requestId}] Attio webhook missing signature header`) + logger.warn('Attio webhook missing signature header', { requestId }) return new NextResponse('Unauthorized - Missing Attio signature', { status: 401, }) @@ -62,7 +63,7 @@ export const attioHandler: WebhookProviderHandler = { const isValidSignature = validateAttioSignature(secret, signature, rawBody) if (!isValidSignature) { - logger.warn(`[${requestId}] Attio signature verification failed`) + logger.warn('Attio signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Attio signature', { status: 401, }) @@ -82,8 +83,9 @@ export const attioHandler: WebhookProviderHandler = { const event = getAttioEvent(obj) const eventType = event?.event_type as string | undefined logger.debug( - `[${requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, + `Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, @@ -114,7 +116,8 @@ export const attioHandler: WebhookProviderHandler = { } if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { + logger.warn('Missing credentialId for Attio webhook creation.', { + requestId, webhookId: webhookRecord.id, }) throw new WebhookDeploymentConfigurationError( @@ -126,7 +129,8 @@ export const attioHandler: WebhookProviderHandler = { if (!accessToken) { logger.warn( - `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` + `Could not retrieve Attio access token for user ${userId}. Cannot create webhook.`, + { requestId } ) throw new Error( 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' @@ -149,7 +153,8 @@ export const attioHandler: WebhookProviderHandler = { } else { const events = TRIGGER_EVENT_MAP[triggerId as string] if (!events || events.length === 0) { - logger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { + logger.warn(`No event types mapped for trigger ${triggerId}`, { + requestId, webhookId: webhookRecord.id, }) throw new Error(`Unknown Attio trigger type: ${triggerId}`) @@ -176,8 +181,8 @@ export const attioHandler: WebhookProviderHandler = { if (!attioResponse.ok) { const errorBody = await attioResponse.json().catch(() => ({})) logger.error( - `[${requestId}] Failed to create webhook in Attio for webhook ${webhookRecord.id}. Status: ${attioResponse.status}`, - { response: errorBody } + `Failed to create webhook in Attio for webhook ${webhookRecord.id}. Status: ${attioResponse.status}`, + { requestId, response: errorBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Attio' @@ -198,37 +203,35 @@ export const attioHandler: WebhookProviderHandler = { if (!webhookId) { logger.error( - `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookRecord.id}`, - { response: responseBody } + `Attio webhook created but no webhook_id returned for webhook ${webhookRecord.id}`, + { requestId, response: responseBody } ) throw new Error('Attio webhook creation succeeded but no webhook ID was returned') } if (!secret) { logger.warn( - `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookRecord.id}. Signature verification will be skipped.`, - { response: responseBody } + `Attio webhook created but no secret returned for webhook ${webhookRecord.id}. Signature verification will be skipped.`, + { requestId, response: responseBody } ) } - logger.info( - `[${requestId}] Successfully created webhook in Attio for webhook ${webhookRecord.id}.`, - { - attioWebhookId: webhookId, - targetUrl: notificationUrl, - subscriptionCount: subscriptions.length, - status: data.status, - } - ) + logger.info(`Successfully created webhook in Attio for webhook ${webhookRecord.id}.`, { + requestId, + attioWebhookId: webhookId, + targetUrl: notificationUrl, + subscriptionCount: subscriptions.length, + status: data.status, + }) return { providerConfigUpdates: { externalId: webhookId, webhookSecret: secret || '' } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const message = toError(error).message - logger.error( - `[${requestId}] Exception during Attio webhook creation for webhook ${webhookRecord.id}.`, - { message } - ) + logger.error(`Exception during Attio webhook creation for webhook ${webhookRecord.id}.`, { + requestId, + message, + }) throw error } }, @@ -245,7 +248,8 @@ export const attioHandler: WebhookProviderHandler = { if (!externalId) { logger.warn( - `[${requestId}] Missing externalId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + `Missing externalId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup`, + { requestId } ) if (strict) throw new Error('Missing Attio externalId for webhook deletion') return @@ -253,7 +257,8 @@ export const attioHandler: WebhookProviderHandler = { if (!credentialId) { logger.warn( - `[${requestId}] Missing credentialId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + `Missing credentialId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup`, + { requestId } ) if (strict) throw new Error('Missing Attio credentialId for webhook deletion') return @@ -262,8 +267,8 @@ export const attioHandler: WebhookProviderHandler = { const accessToken = await getCredentialAccessToken(credentialId, requestId) if (!accessToken) { - const message = `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.` - logger.warn(message, { webhookId: webhookRecord.id }) + const message = 'Could not retrieve Attio access token. Cannot delete webhook.' + logger.warn(message, { requestId, webhookId: webhookRecord.id }) if (strict) throw new Error(message) return } @@ -277,16 +282,16 @@ export const attioHandler: WebhookProviderHandler = { if (!attioResponse.ok && attioResponse.status !== 404) { const responseBody = await attioResponse.json().catch(() => ({})) - logger.warn( - `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, { + requestId, + response: responseBody, + }) if (strict) throw new Error(`Failed to delete Attio webhook: ${attioResponse.status}`) } else { - logger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) + logger.info(`Successfully deleted Attio webhook ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) + logger.warn('Error deleting Attio webhook (non-fatal)', error, { requestId }) if (strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/azure-devops.ts b/apps/sim/lib/webhooks/providers/azure-devops.ts index 557443344c2..27daf850714 100644 --- a/apps/sim/lib/webhooks/providers/azure-devops.ts +++ b/apps/sim/lib/webhooks/providers/azure-devops.ts @@ -30,15 +30,13 @@ export const azureDevOpsHandler: WebhookProviderHandler = { if (triggerId && triggerId !== 'azure_devops_webhook') { const { isAzureDevOpsEventMatch } = await import('@/triggers/azure_devops/utils') if (!isAzureDevOpsEventMatch(triggerId, b)) { - logger.debug( - `[${requestId}] Azure DevOps event mismatch for trigger ${triggerId}. Skipping execution.`, - { - webhookId: webhook.id, - workflowId: workflow.id, - triggerId, - eventType: b.eventType, - } - ) + logger.debug(`Azure DevOps event mismatch for trigger ${triggerId}. Skipping execution.`, { + requestId, + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + eventType: b.eventType, + }) return false } } @@ -77,7 +75,8 @@ export const azureDevOpsHandler: WebhookProviderHandler = { return { input: formatWorkItemCreatedInput(b) } } - logger.warn(`[${requestId}] Azure DevOps: unknown eventType for specialized trigger`, { + logger.warn('Azure DevOps: unknown eventType for specialized trigger', { + requestId, triggerId, eventType, }) diff --git a/apps/sim/lib/webhooks/providers/bitbucket.ts b/apps/sim/lib/webhooks/providers/bitbucket.ts index 3ded1288f57..e0fa832499c 100644 --- a/apps/sim/lib/webhooks/providers/bitbucket.ts +++ b/apps/sim/lib/webhooks/providers/bitbucket.ts @@ -190,7 +190,8 @@ async function findBitbucketCandidateHook( headers: bitbucketHeaders(accessToken), }) } catch (error) { - logger.warn(`[${requestId}] Could not inspect Bitbucket hooks for candidate reconciliation`, { + logger.warn('Could not inspect Bitbucket hooks for candidate reconciliation', { + requestId, error: getErrorMessage(error), }) return { @@ -259,16 +260,17 @@ async function deleteProvenBitbucketCandidate( } ) } catch (error) { - logger.warn(`[${requestId}] Failed to delete proven Bitbucket candidate ${externalId}`, { + logger.warn(`Failed to delete proven Bitbucket candidate ${externalId}`, { + requestId, error: getErrorMessage(error), }) return false } if (!response.ok && response.status !== 404) { - logger.warn( - `[${requestId}] Failed to delete proven Bitbucket candidate ${externalId} (${response.status})` - ) + logger.warn(`Failed to delete proven Bitbucket candidate ${externalId} (${response.status})`, { + requestId, + }) return false } @@ -293,13 +295,11 @@ async function rollbackAmbiguousBitbucketCandidate( ) if (candidate.kind !== 'found') { - logger.warn( - `[${requestId}] Bitbucket candidate cleanup skipped because identity was not unique`, - { - lookup: candidate.kind, - matchCount: candidate.kind === 'ambiguous' ? candidate.matchCount : undefined, - } - ) + logger.warn('Bitbucket candidate cleanup skipped because identity was not unique', { + requestId, + lookup: candidate.kind, + matchCount: candidate.kind === 'ambiguous' ? candidate.matchCount : undefined, + }) return } @@ -455,7 +455,8 @@ export const bitbucketHandler: WebhookProviderHandler = { if (!triggerId || !isBitbucketEventMatch(triggerId, eventKey)) { logger.debug( - `[${requestId}] Bitbucket event '${eventKey}' does not match trigger '${triggerId || 'unknown'}', skipping` + `Bitbucket event '${eventKey}' does not match trigger '${triggerId || 'unknown'}', skipping`, + { requestId } ) return false } @@ -519,9 +520,9 @@ export const bitbucketHandler: WebhookProviderHandler = { existingCandidate.events.length === 1 && existingCandidate.events[0] === eventKey if (candidateMatchesCheckpoint && checkpointedSecret) { - logger.info( - `[${ctx.requestId}] Reusing checkpointed Bitbucket candidate ${existingCandidate.externalId}` - ) + logger.info(`Reusing checkpointed Bitbucket candidate ${existingCandidate.externalId}`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { externalId: existingCandidate.externalId, @@ -576,7 +577,8 @@ export const bitbucketHandler: WebhookProviderHandler = { if (!response.ok) { const detail = await readBitbucketErrorDetail(response) - logger.error(`[${ctx.requestId}] Failed to create Bitbucket webhook (${response.status})`, { + logger.error(`Failed to create Bitbucket webhook (${response.status})`, { + requestId: ctx.requestId, detail, workspaceSlug, repoSlug, @@ -612,9 +614,9 @@ export const bitbucketHandler: WebhookProviderHandler = { throw new Error('Bitbucket webhook was created but no hook UUID was returned.') } - logger.info( - `[${ctx.requestId}] Created Bitbucket webhook ${externalId} for ${workspaceSlug}/${repoSlug}` - ) + logger.info(`Created Bitbucket webhook ${externalId} for ${workspaceSlug}/${repoSlug}`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { externalId, webhookSecret, eventTypes: [eventKey] }, } @@ -634,7 +636,7 @@ export const bitbucketHandler: WebhookProviderHandler = { const message = 'Missing Bitbucket credential, workspace, repository, or hook UUID for webhook deletion.' if (ctx.strict) throw new Error(message) - logger.warn(`[${ctx.requestId}] ${message}`) + logger.warn(message, { requestId: ctx.requestId }) return } @@ -653,9 +655,11 @@ export const bitbucketHandler: WebhookProviderHandler = { throw createBitbucketApiError(response.status, 'delete', detail) } - logger.info(`[${ctx.requestId}] Deleted Bitbucket webhook ${externalId}`) + logger.info(`Deleted Bitbucket webhook ${externalId}`, { requestId: ctx.requestId }) } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Bitbucket webhook (non-fatal)`, error) + logger.warn('Error deleting Bitbucket webhook (non-fatal)', error, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts index 83445bf7cfc..9bf401df6b9 100644 --- a/apps/sim/lib/webhooks/providers/calendly.ts +++ b/apps/sim/lib/webhooks/providers/calendly.ts @@ -35,7 +35,8 @@ export const calendlyHandler: WebhookProviderHandler = { } if (!apiKey) { - logger.warn(`[${ctx.requestId}] Missing apiKey for Calendly webhook creation.`, { + logger.warn('Missing apiKey for Calendly webhook creation.', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -44,7 +45,8 @@ export const calendlyHandler: WebhookProviderHandler = { } if (!organization) { - logger.warn(`[${ctx.requestId}] Missing organization URI for Calendly webhook creation.`, { + logger.warn('Missing organization URI for Calendly webhook creation.', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -53,7 +55,8 @@ export const calendlyHandler: WebhookProviderHandler = { } if (!triggerId) { - logger.warn(`[${ctx.requestId}] Missing triggerId for Calendly webhook creation.`, { + logger.warn('Missing triggerId for Calendly webhook creation.', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -101,8 +104,8 @@ export const calendlyHandler: WebhookProviderHandler = { (errorBody as Record).title || 'Unknown Calendly API error' logger.error( - `[${ctx.requestId}] Failed to create webhook in Calendly for webhook ${ctx.webhook.id}. Status: ${calendlyResponse.status}`, - { response: errorBody } + `Failed to create webhook in Calendly for webhook ${ctx.webhook.id}. Status: ${calendlyResponse.status}`, + { requestId: ctx.requestId, response: errorBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' @@ -128,8 +131,8 @@ export const calendlyHandler: WebhookProviderHandler = { if (!webhookUri) { logger.error( - `[${ctx.requestId}] Calendly webhook created but no webhook URI returned for webhook ${ctx.webhook.id}`, - { response: responseBody } + `Calendly webhook created but no webhook URI returned for webhook ${ctx.webhook.id}`, + { requestId: ctx.requestId, response: responseBody } ) throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') } @@ -137,33 +140,27 @@ export const calendlyHandler: WebhookProviderHandler = { const webhookId = webhookUri.split('/').pop() if (!webhookId) { - logger.error( - `[${ctx.requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, - { - response: responseBody, - } - ) + logger.error(`Could not extract webhook ID from Calendly URI: ${webhookUri}`, { + requestId: ctx.requestId, + response: responseBody, + }) throw new Error('Failed to extract webhook ID from Calendly response') } - logger.info( - `[${ctx.requestId}] Successfully created webhook in Calendly for webhook ${ctx.webhook.id}.`, - { - calendlyWebhookUri: webhookUri, - calendlyWebhookId: webhookId, - } - ) + logger.info(`Successfully created webhook in Calendly for webhook ${ctx.webhook.id}.`, { + requestId: ctx.requestId, + calendlyWebhookUri: webhookUri, + calendlyWebhookId: webhookId, + }) return { providerConfigUpdates: { externalId: webhookId } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${ctx.requestId}] Exception during Calendly webhook creation for webhook ${ctx.webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Calendly webhook creation for webhook ${ctx.webhook.id}.`, { + requestId: ctx.requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -176,7 +173,8 @@ export const calendlyHandler: WebhookProviderHandler = { if (!apiKey) { logger.warn( - `[${ctx.requestId}] Missing apiKey for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing apiKey for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Calendly apiKey for webhook deletion') return @@ -184,7 +182,8 @@ export const calendlyHandler: WebhookProviderHandler = { if (!externalId) { logger.warn( - `[${ctx.requestId}] Missing externalId for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing externalId for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Calendly externalId for webhook deletion') return @@ -201,20 +200,22 @@ export const calendlyHandler: WebhookProviderHandler = { if (!calendlyResponse.ok && calendlyResponse.status !== 404) { const responseBody = await calendlyResponse.json().catch(() => ({})) - logger.warn( - `[${ctx.requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, { + requestId: ctx.requestId, + response: responseBody, + }) if (ctx.strict) { throw new Error(`Failed to delete Calendly webhook: ${calendlyResponse.status}`) } } else { - logger.info( - `[${ctx.requestId}] Successfully deleted Calendly webhook subscription ${externalId}` - ) + logger.info(`Successfully deleted Calendly webhook subscription ${externalId}`, { + requestId: ctx.requestId, + }) } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Calendly webhook (non-fatal)`, error) + logger.warn('Error deleting Calendly webhook (non-fatal)', error, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/checkr.ts b/apps/sim/lib/webhooks/providers/checkr.ts index 2896636da71..a7618573f5c 100644 --- a/apps/sim/lib/webhooks/providers/checkr.ts +++ b/apps/sim/lib/webhooks/providers/checkr.ts @@ -128,7 +128,7 @@ export const checkrHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { const apiKey = typeof providerConfig.apiKey === 'string' ? providerConfig.apiKey.trim() : '' if (!apiKey) { - logger.warn(`[${requestId}] Checkr webhook has no API key configured — rejecting request`) + logger.warn('Checkr webhook has no API key configured — rejecting request', { requestId }) return new NextResponse( 'Unauthorized - Checkr API key is not configured. Add it to the trigger and redeploy.', { status: 401 } @@ -137,12 +137,12 @@ export const checkrHandler: WebhookProviderHandler = { const signature = request.headers.get('x-checkr-signature') if (!signature) { - logger.warn(`[${requestId}] Checkr webhook missing X-Checkr-Signature header`) + logger.warn('Checkr webhook missing X-Checkr-Signature header', { requestId }) return new NextResponse('Unauthorized - Missing Checkr signature', { status: 401 }) } if (!isValidCheckrSignature(apiKey, signature, rawBody)) { - logger.warn(`[${requestId}] Checkr signature verification failed`) + logger.warn('Checkr signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Checkr signature', { status: 401 }) } @@ -162,8 +162,8 @@ export const checkrHandler: WebhookProviderHandler = { const { isCheckrEventMatch } = await import('@/triggers/checkr/utils') if (!isCheckrEventMatch(triggerId, eventType)) { logger.debug( - `[${requestId}] Checkr event ${eventType || '(missing)'} does not match trigger ${triggerId}. Skipping execution.`, - { webhookId: webhook.id, triggerId } + `Checkr event ${eventType || '(missing)'} does not match trigger ${triggerId}. Skipping execution.`, + { requestId, webhookId: webhook.id, triggerId } ) return false } @@ -207,11 +207,14 @@ export const checkrHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(ctx.webhook) const existingId = await findCheckrWebhookForUrl(apiKey, notificationUrl) if (existingId) { - logger.info(`[${ctx.requestId}] Reusing Checkr webhook ${existingId} for this callback URL`) + logger.info(`Reusing Checkr webhook ${existingId} for this callback URL`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { externalId: existingId } } } - logger.info(`[${ctx.requestId}] Creating Checkr webhook`, { + logger.info('Creating Checkr webhook', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, triggerId: providerConfig.triggerId, }) @@ -249,7 +252,7 @@ export const checkrHandler: WebhookProviderHandler = { throw new Error('Checkr created the webhook but did not return its ID.') } - logger.info(`[${ctx.requestId}] Created Checkr webhook ${externalId}`) + logger.info(`Created Checkr webhook ${externalId}`, { requestId: ctx.requestId }) return { providerConfigUpdates: { externalId } } }, @@ -261,7 +264,8 @@ export const checkrHandler: WebhookProviderHandler = { if (!apiKey || !externalId) { logger.warn( - `[${ctx.requestId}] Missing ${apiKey ? 'externalId' : 'apiKey'} for Checkr webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing ${apiKey ? 'externalId' : 'apiKey'} for Checkr webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Checkr API key or webhook ID for deletion') return @@ -269,7 +273,8 @@ export const checkrHandler: WebhookProviderHandler = { if (await activeDeploymentUsesCheckrWebhook(ctx.webhook, externalId)) { logger.info( - `[${ctx.requestId}] Keeping Checkr webhook ${externalId} because an active deployment still uses it` + `Keeping Checkr webhook ${externalId} because an active deployment still uses it`, + { requestId: ctx.requestId } ) return } @@ -280,24 +285,27 @@ export const checkrHandler: WebhookProviderHandler = { }) if (response.ok) { - logger.info(`[${ctx.requestId}] Deleted Checkr webhook ${externalId}`) + logger.info(`Deleted Checkr webhook ${externalId}`, { requestId: ctx.requestId }) return } if (response.status === 404) { - logger.info(`[${ctx.requestId}] Checkr webhook ${externalId} was already removed`) + logger.info(`Checkr webhook ${externalId} was already removed`, { + requestId: ctx.requestId, + }) return } const responseBody = await readCheckrResponse(response, 'Checkr webhook deletion response') const message = checkrErrorMessage(responseBody) ?? `HTTP ${response.status}` - logger.warn(`[${ctx.requestId}] Failed to delete Checkr webhook (non-fatal): ${message}`, { + logger.warn(`Failed to delete Checkr webhook (non-fatal): ${message}`, { + requestId: ctx.requestId, status: response.status, }) if (ctx.strict) throw new Error(`Failed to delete Checkr webhook: ${message}`) } catch (error) { - logger.warn( - `[${ctx.requestId}] Error deleting Checkr webhook (non-fatal): ${getErrorMessage(error)}` - ) + logger.warn(`Error deleting Checkr webhook (non-fatal): ${getErrorMessage(error)}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/clerk.ts b/apps/sim/lib/webhooks/providers/clerk.ts index 631d1e03fd5..ed07751e473 100644 --- a/apps/sim/lib/webhooks/providers/clerk.ts +++ b/apps/sim/lib/webhooks/providers/clerk.ts @@ -20,7 +20,7 @@ export const clerkHandler: WebhookProviderHandler = { }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { - logger.warn(`[${requestId}] Clerk webhook missing signing secret in provider configuration`) + logger.warn('Clerk webhook missing signing secret in provider configuration', { requestId }) return new NextResponse('Unauthorized - Clerk signing secret is required', { status: 401 }) } @@ -47,7 +47,8 @@ export const clerkHandler: WebhookProviderHandler = { if (!isClerkEventMatch(triggerId, body as Record)) { const actualType = (body as Record)?.type logger.debug( - `[${requestId}] Clerk event type mismatch for trigger ${triggerId}, got ${String(actualType)}. Skipping.` + `Clerk event type mismatch for trigger ${triggerId}, got ${String(actualType)}. Skipping.`, + { requestId } ) return false } diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts index a411b4be055..44593b59d24 100644 --- a/apps/sim/lib/webhooks/providers/clickup.ts +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -110,12 +110,8 @@ export const clickupHandler: WebhookProviderHandler = { const { isClickUpEventMatch, getClickUpEventType } = await import('@/triggers/clickup/utils') if (!isClickUpEventMatch(triggerId, obj)) { logger.debug( - `[${requestId}] ClickUp event mismatch for trigger ${triggerId}. Event: ${getClickUpEventType(obj)}. Skipping execution.`, - { - webhookId: webhook.id, - workflowId: workflow.id, - triggerId, - } + `ClickUp event mismatch for trigger ${triggerId}. Event: ${getClickUpEventType(obj)}. Skipping execution.`, + { requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId } ) return NextResponse.json({ status: 'skipped', @@ -203,8 +199,8 @@ export const clickupHandler: WebhookProviderHandler = { if (!clickupResponse.ok) { const errorBody = (await clickupResponse.json().catch(() => ({}))) as { err?: string } logger.error( - `[${requestId}] Failed to create webhook in ClickUp for webhook ${webhookRecord.id}. Status: ${clickupResponse.status}`, - { response: errorBody } + `Failed to create webhook in ClickUp for webhook ${webhookRecord.id}. Status: ${clickupResponse.status}`, + { requestId, response: errorBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in ClickUp' @@ -239,23 +235,24 @@ export const clickupHandler: WebhookProviderHandler = { if (!externalId) { logger.error( - `[${requestId}] ClickUp webhook created but no webhook id returned for webhook ${webhookRecord.id}`, - { response: redactedResponse } + `ClickUp webhook created but no webhook id returned for webhook ${webhookRecord.id}`, + { requestId, response: redactedResponse } ) throw new Error('ClickUp webhook creation succeeded but no webhook ID was returned') } if (!secret) { logger.error( - `[${requestId}] ClickUp webhook created but no secret returned for webhook ${webhookRecord.id}. Rolling back.`, - { response: redactedResponse } + `ClickUp webhook created but no secret returned for webhook ${webhookRecord.id}. Rolling back.`, + { requestId, response: redactedResponse } ) const rollback = await deleteClickUpWebhook(accessToken, externalId).catch(() => undefined) if (!rollback || (!rollback.ok && rollback.status !== 404)) { - logger.error( - `[${requestId}] Failed to roll back ClickUp webhook ${externalId} after missing secret`, - { clickupWebhookId: externalId, status: rollback?.status } - ) + logger.error(`Failed to roll back ClickUp webhook ${externalId} after missing secret`, { + requestId, + clickupWebhookId: externalId, + status: rollback?.status, + }) throw new Error( `ClickUp webhook creation succeeded but no signing secret was returned, and rolling back the webhook failed. Delete webhook ${externalId} manually in ClickUp before retrying.` ) @@ -263,23 +260,21 @@ export const clickupHandler: WebhookProviderHandler = { throw new Error('ClickUp webhook creation succeeded but no signing secret was returned') } - logger.info( - `[${requestId}] Successfully created webhook in ClickUp for webhook ${webhookRecord.id}.`, - { - clickupWebhookId: externalId, - workspaceId, - events, - } - ) + logger.info(`Successfully created webhook in ClickUp for webhook ${webhookRecord.id}.`, { + requestId, + clickupWebhookId: externalId, + workspaceId, + events, + }) return { providerConfigUpdates: { externalId, webhookSecret: secret } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const message = toError(error).message - logger.error( - `[${requestId}] Exception during ClickUp webhook creation for webhook ${webhookRecord.id}.`, - { message } - ) + logger.error(`Exception during ClickUp webhook creation for webhook ${webhookRecord.id}.`, { + requestId, + message, + }) throw error } }, @@ -296,7 +291,8 @@ export const clickupHandler: WebhookProviderHandler = { if (!externalId) { logger.warn( - `[${requestId}] Missing externalId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup` + `Missing externalId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup`, + { requestId } ) if (strict) throw new Error('Missing ClickUp externalId for webhook deletion') return @@ -304,7 +300,8 @@ export const clickupHandler: WebhookProviderHandler = { if (!credentialId) { logger.warn( - `[${requestId}] Missing credentialId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup` + `Missing credentialId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup`, + { requestId } ) if (strict) throw new Error('Missing ClickUp credentialId for webhook deletion') return @@ -313,8 +310,8 @@ export const clickupHandler: WebhookProviderHandler = { const accessToken = await getCredentialAccessToken(credentialId, requestId) if (!accessToken) { - const message = `[${requestId}] Could not retrieve ClickUp access token. Cannot delete webhook.` - logger.warn(message, { webhookId: webhookRecord.id }) + const message = 'Could not retrieve ClickUp access token. Cannot delete webhook.' + logger.warn(message, { requestId, webhookId: webhookRecord.id }) if (strict) throw new Error(message) return } @@ -323,16 +320,16 @@ export const clickupHandler: WebhookProviderHandler = { if (!clickupResponse.ok && clickupResponse.status !== 404) { const responseBody = await clickupResponse.json().catch(() => ({})) - logger.warn( - `[${requestId}] Failed to delete ClickUp webhook (non-fatal): ${clickupResponse.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete ClickUp webhook (non-fatal): ${clickupResponse.status}`, { + requestId, + response: responseBody, + }) if (strict) throw new Error(`Failed to delete ClickUp webhook: ${clickupResponse.status}`) } else { - logger.info(`[${requestId}] Successfully deleted ClickUp webhook ${externalId}`) + logger.info(`Successfully deleted ClickUp webhook ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting ClickUp webhook (non-fatal)`, error) + logger.warn('Error deleting ClickUp webhook (non-fatal)', error, { requestId }) if (strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/confluence.ts b/apps/sim/lib/webhooks/providers/confluence.ts index f828555efcd..bfb31066a1b 100644 --- a/apps/sim/lib/webhooks/providers/confluence.ts +++ b/apps/sim/lib/webhooks/providers/confluence.ts @@ -104,15 +104,13 @@ export const confluenceHandler: WebhookProviderHandler = { if (triggerId) { const { isConfluencePayloadMatch } = await import('@/triggers/confluence/utils') if (!isConfluencePayloadMatch(triggerId, obj)) { - logger.debug( - `[${requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, - { - webhookId: webhook.id, - workflowId: workflow.id, - triggerId, - bodyKeys: Object.keys(obj), - } - ) + logger.debug(`Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, { + requestId, + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + bodyKeys: Object.keys(obj), + }) return NextResponse.json({ message: 'Payload does not match trigger configuration. Ignoring.', }) diff --git a/apps/sim/lib/webhooks/providers/emailbison.ts b/apps/sim/lib/webhooks/providers/emailbison.ts index cdbcce6dfcd..1969a75c7a2 100644 --- a/apps/sim/lib/webhooks/providers/emailbison.ts +++ b/apps/sim/lib/webhooks/providers/emailbison.ts @@ -27,15 +27,13 @@ export const emailBisonHandler: WebhookProviderHandler = { if (!triggerId) return true if (!isRecordLike(body)) { - logger.warn(`[${requestId}] Email Bison webhook payload was not an object`) + logger.warn('Email Bison webhook payload was not an object', { requestId }) return false } const { isEmailBisonEventMatch } = await import('@/triggers/emailbison/utils') if (!isEmailBisonEventMatch(triggerId, unwrapEmailBisonPayload(body))) { - logger.info(`[${requestId}] Email Bison event did not match trigger`, { - triggerId, - }) + logger.info('Email Bison event did not match trigger', { requestId, triggerId }) return false } @@ -147,7 +145,8 @@ export const emailBisonHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(webhook) - logger.info(`[${requestId}] Creating Email Bison webhook`, { + logger.info('Creating Email Bison webhook', { + requestId, triggerId, eventType, webhookId: webhook.id, @@ -156,7 +155,7 @@ export const emailBisonHandler: WebhookProviderHandler = { const targetUrl = emailBisonUrl('/api/webhook-url', {}, apiBaseUrl) const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl', 'configuredEndpoint') if (!urlValidation.isValid) { - logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`) + logger.warn(`Invalid Email Bison Instance URL: ${urlValidation.error}`, { requestId }) throw new Error('Email Bison Instance URL could not be validated.') } @@ -174,7 +173,8 @@ export const emailBisonHandler: WebhookProviderHandler = { const responseBody = await parseJsonResponse(response) if (!response.ok) { const message = extractEmailBisonError(responseBody) - logger.error(`[${requestId}] Failed to create Email Bison webhook`, { + logger.error('Failed to create Email Bison webhook', { + requestId, status: response.status, message, response: responseBody, @@ -197,7 +197,8 @@ export const emailBisonHandler: WebhookProviderHandler = { throw new Error('Email Bison webhook was created but the API response did not include an ID.') } - logger.info(`[${requestId}] Successfully created Email Bison webhook`, { + logger.info('Successfully created Email Bison webhook', { + requestId, externalId, webhookId: webhook.id, }) @@ -215,7 +216,8 @@ export const emailBisonHandler: WebhookProviderHandler = { const externalId = providerConfig.externalId as string | undefined if (!apiKey?.trim() || !apiBaseUrl?.trim() || !externalId?.trim()) { - logger.warn(`[${requestId}] Missing Email Bison webhook cleanup configuration`, { + logger.warn('Missing Email Bison webhook cleanup configuration', { + requestId, webhookId: webhook.id, hasApiKey: Boolean(apiKey), hasApiBaseUrl: Boolean(apiBaseUrl), @@ -233,7 +235,8 @@ export const emailBisonHandler: WebhookProviderHandler = { ) const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl', 'configuredEndpoint') if (!urlValidation.isValid) { - logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`, { + logger.warn(`Invalid Email Bison Instance URL: ${urlValidation.error}`, { + requestId, webhookId: webhook.id, }) if (ctx.strict) @@ -249,7 +252,8 @@ export const emailBisonHandler: WebhookProviderHandler = { if (!response.ok && response.status !== 404) { const responseBody = await parseJsonResponse(response) - logger.warn(`[${requestId}] Failed to delete Email Bison webhook`, { + logger.warn('Failed to delete Email Bison webhook', { + requestId, status: response.status, response: responseBody, }) @@ -259,13 +263,15 @@ export const emailBisonHandler: WebhookProviderHandler = { } await response.body?.cancel() - logger.info(`[${requestId}] Successfully deleted Email Bison webhook`, { + logger.info('Successfully deleted Email Bison webhook', { + requestId, externalId, webhookId: webhook.id, }) } catch (error) { if (!(error instanceof AlreadyLoggedError)) { - logger.warn(`[${requestId}] Error deleting Email Bison webhook`, { + logger.warn('Error deleting Email Bison webhook', { + requestId, message: toError(error).message, }) } diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts index 5299edadeb2..27d5f6f7534 100644 --- a/apps/sim/lib/webhooks/providers/fathom.ts +++ b/apps/sim/lib/webhooks/providers/fathom.ts @@ -25,7 +25,8 @@ export const fathomHandler: WebhookProviderHandler = { const includeCrmMatches = providerConfig.includeCrmMatches as unknown if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { + logger.warn('Missing apiKey for Fathom webhook creation.', { + requestId, webhookId: webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -51,7 +52,8 @@ export const fathomHandler: WebhookProviderHandler = { include_crm_matches: toBool(includeCrmMatches, false), } - logger.info(`[${requestId}] Creating Fathom webhook`, { + logger.info('Creating Fathom webhook', { + requestId, triggerId, triggeredFor: triggeredForValue, webhookId: webhook.id, @@ -77,8 +79,8 @@ export const fathomHandler: WebhookProviderHandler = { (responseBody.error as string) || 'Unknown Fathom API error' logger.error( - `[${requestId}] Failed to create webhook in Fathom for webhook ${webhook.id}. Status: ${fathomResponse.status}`, - { message: errorMessage, response: responseBody } + `Failed to create webhook in Fathom for webhook ${webhook.id}. Status: ${fathomResponse.status}`, + { requestId, message: errorMessage, response: responseBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' @@ -95,29 +97,26 @@ export const fathomHandler: WebhookProviderHandler = { if (!responseBody.id) { logger.error( - `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhook.id}.` + `Fathom webhook creation returned success but no webhook ID for ${webhook.id}.`, + { requestId } ) throw new Error('Fathom webhook created but no ID returned. Please try again.') } - logger.info( - `[${requestId}] Successfully created webhook in Fathom for webhook ${webhook.id}.`, - { - fathomWebhookId: responseBody.id, - } - ) + logger.info(`Successfully created webhook in Fathom for webhook ${webhook.id}.`, { + requestId, + fathomWebhookId: responseBody.id, + }) return { providerConfigUpdates: { externalId: responseBody.id } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Fathom webhook creation for webhook ${webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Fathom webhook creation for webhook ${webhook.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -130,16 +129,17 @@ export const fathomHandler: WebhookProviderHandler = { const externalId = config.externalId as string | undefined if (!apiKey) { - logger.warn( - `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) + logger.warn(`Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup`, { + requestId, + }) if (ctx.strict) throw new Error('Missing Fathom apiKey for webhook deletion') return } if (!externalId) { logger.warn( - `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` + `Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) throw new Error('Missing Fathom externalId for webhook deletion') return @@ -148,7 +148,8 @@ export const fathomHandler: WebhookProviderHandler = { const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) if (!idValidation.isValid) { logger.warn( - `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` + `Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) throw new Error('Invalid Fathom externalId for webhook deletion') return @@ -165,15 +166,15 @@ export const fathomHandler: WebhookProviderHandler = { }) if (!fathomResponse.ok && fathomResponse.status !== 404) { - logger.warn( - `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` - ) + logger.warn(`Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}`, { + requestId, + }) if (ctx.strict) throw new Error(`Failed to delete Fathom webhook: ${fathomResponse.status}`) } else { - logger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) + logger.info(`Successfully deleted Fathom webhook ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) + logger.warn('Error deleting Fathom webhook (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/fivetran.ts b/apps/sim/lib/webhooks/providers/fivetran.ts index f17ded3337c..c471257bb47 100644 --- a/apps/sim/lib/webhooks/providers/fivetran.ts +++ b/apps/sim/lib/webhooks/providers/fivetran.ts @@ -131,7 +131,7 @@ export const fivetranHandler: WebhookProviderHandler = { const secret = readString(providerConfig, 'webhookSecret') if (!secret) { - logger.warn(`[${requestId}] Fivetran webhook has no signing secret — rejecting request`) + logger.warn('Fivetran webhook has no signing secret — rejecting request', { requestId }) return new NextResponse( 'Unauthorized - Fivetran webhook signing secret is missing. Redeploy the workflow to register the webhook.', { status: 401 } @@ -139,12 +139,12 @@ export const fivetranHandler: WebhookProviderHandler = { } if (!signature) { - logger.warn(`[${requestId}] Fivetran webhook missing X-Fivetran-Signature-256 header`) + logger.warn('Fivetran webhook missing X-Fivetran-Signature-256 header', { requestId }) return new NextResponse('Unauthorized - Missing Fivetran signature', { status: 401 }) } if (!isValidFivetranSignature(secret, signature, rawBody)) { - logger.warn(`[${requestId}] Fivetran signature verification failed`) + logger.warn('Fivetran signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Fivetran signature', { status: 401 }) } @@ -164,7 +164,8 @@ export const fivetranHandler: WebhookProviderHandler = { (!request.headers.get(SIGNATURE_HEADER) || payload.connector_id === FIVETRAN_TEST_CONNECTION_ID) ) { - logger.info(`[${requestId}] Acknowledged Fivetran activation test without running`, { + logger.info('Acknowledged Fivetran activation test without running', { + requestId, webhookId: webhook.id, }) return false @@ -176,8 +177,8 @@ export const fivetranHandler: WebhookProviderHandler = { const { isFivetranEventMatch } = await import('@/triggers/fivetran/utils') if (!isFivetranEventMatch(triggerId, event)) { logger.debug( - `[${requestId}] Fivetran event ${event || '(missing)'} does not match trigger ${triggerId}. Skipping execution.`, - { webhookId: webhook.id, triggerId } + `Fivetran event ${event || '(missing)'} does not match trigger ${triggerId}. Skipping execution.`, + { requestId, webhookId: webhook.id, triggerId } ) return false } @@ -190,7 +191,8 @@ export const fivetranHandler: WebhookProviderHandler = { !event.startsWith('transformation_') && payload.connector_id !== connectionId ) { - logger.debug(`[${requestId}] Fivetran event is for another connection. Skipping execution.`, { + logger.debug('Fivetran event is for another connection. Skipping execution.', { + requestId, webhookId: webhook.id, }) return false @@ -245,7 +247,8 @@ export const fivetranHandler: WebhookProviderHandler = { : `${FIVETRAN_WEBHOOKS_URL}/account` const webhookSecret = generateRandomHex(64) - logger.info(`[${ctx.requestId}] Creating Fivetran ${groupId ? 'group' : 'account'} webhook`, { + logger.info(`Creating Fivetran ${groupId ? 'group' : 'account'} webhook`, { + requestId: ctx.requestId, webhookId: ctx.webhook.id, triggerId, }) @@ -266,7 +269,7 @@ export const fivetranHandler: WebhookProviderHandler = { const externalId = toStringOrNull(toRecord(responseBody.data).id) if (!externalId) throw new Error('Fivetran created the webhook but did not return its ID.') - logger.info(`[${ctx.requestId}] Created inactive Fivetran webhook ${externalId}`) + logger.info(`Created inactive Fivetran webhook ${externalId}`, { requestId: ctx.requestId }) return { providerConfigUpdates: { externalId, webhookSecret, subscriptionActivationPending: true }, } @@ -290,7 +293,7 @@ export const fivetranHandler: WebhookProviderHandler = { ) throw new Error(fivetranFailureMessage('activate', response.status, responseBody)) } - logger.info(`[${ctx.requestId}] Activated Fivetran webhook ${externalId}`) + logger.info(`Activated Fivetran webhook ${externalId}`, { requestId: ctx.requestId }) }, async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { @@ -300,7 +303,8 @@ export const fivetranHandler: WebhookProviderHandler = { const externalId = readString(providerConfig, 'externalId') if (!credentials || !externalId) { logger.warn( - `[${ctx.requestId}] Missing ${credentials ? 'externalId' : 'API credentials'} for Fivetran webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing ${credentials ? 'externalId' : 'API credentials'} for Fivetran webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Fivetran API credentials or webhook ID for deletion') @@ -309,7 +313,7 @@ export const fivetranHandler: WebhookProviderHandler = { const response = await fivetranRequest(credentials, webhookUrl(externalId), 'DELETE') if (response.ok || response.status === 404) { - logger.info(`[${ctx.requestId}] Removed Fivetran webhook ${externalId}`) + logger.info(`Removed Fivetran webhook ${externalId}`, { requestId: ctx.requestId }) return } @@ -318,12 +322,12 @@ export const fivetranHandler: WebhookProviderHandler = { 'Fivetran webhook deletion response' ) const message = fivetranFailureMessage('delete', response.status, responseBody) - logger.warn(`[${ctx.requestId}] ${message} (non-fatal)`) + logger.warn(`${message} (non-fatal)`, { requestId: ctx.requestId }) if (ctx.strict) throw new Error(message) } catch (error) { - logger.warn( - `[${ctx.requestId}] Error deleting Fivetran webhook (non-fatal): ${getErrorMessage(error)}` - ) + logger.warn(`Error deleting Fivetran webhook (non-fatal): ${getErrorMessage(error)}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts index 807a3930181..30eed47b0c4 100644 --- a/apps/sim/lib/webhooks/providers/generic.ts +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -117,8 +117,8 @@ function mergeRequestData( if (!isRecordLike(body)) { logger.debug( - `[${requestId}] Dropping webhook request metadata: the body is not an object, so there is no field to merge it into`, - { keys: entries.map(([key]) => key) } + 'Dropping webhook request metadata: the body is not an object, so there is no field to merge it into', + { requestId, keys: entries.map(([key]) => key) } ) return body } @@ -127,9 +127,9 @@ function mergeRequestData( for (const [key, value] of entries) { if (Object.hasOwn(body, key)) { - logger.debug( - `[${requestId}] Dropping webhook ${key}: the body already defines a "${key}" field` - ) + logger.debug(`Dropping webhook ${key}: the body already defines a "${key}" field`, { + requestId, + }) continue } merged[key] = value @@ -168,7 +168,7 @@ export const genericHandler: WebhookProviderHandler = { ) if (!clientIp || !clientIpAllowed) { - logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`) + logger.warn(`Forbidden webhook access attempt - IP not allowed: ${clientIp}`, { requestId }) return new NextResponse('Forbidden - IP not allowed', { status: 403, }) @@ -300,7 +300,8 @@ export const genericHandler: WebhookProviderHandler = { if (uploadedFiles.length > 0) { input[fileField.name] = uploadedFiles logger.info( - `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` + `Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}`, + { requestId } ) } } diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts index 0c158b82f0a..0400fef1dbd 100644 --- a/apps/sim/lib/webhooks/providers/github.ts +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -98,12 +98,13 @@ export const githubHandler: WebhookProviderHandler = { const signature = request.headers.get('X-Hub-Signature-256') || request.headers.get('X-Hub-Signature') if (!signature) { - logger.warn(`[${requestId}] GitHub webhook missing signature header`) + logger.warn('GitHub webhook missing signature header', { requestId }) return new NextResponse('Unauthorized - Missing GitHub signature', { status: 401 }) } if (!validateGitHubSignature(secret, signature, rawBody)) { - logger.warn(`[${requestId}] GitHub signature verification failed`, { + logger.warn('GitHub signature verification failed', { + requestId, usingSha256: !!request.headers.get('X-Hub-Signature-256'), }) return new NextResponse('Unauthorized - Invalid GitHub signature', { status: 401 }) @@ -155,8 +156,9 @@ export const githubHandler: WebhookProviderHandler = { const { isGitHubEventMatch } = await import('@/triggers/github/utils') if (!isGitHubEventMatch(triggerId, eventType || '', action, obj)) { logger.debug( - `[${requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, + `GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index cbb99aed691..1a5341f66f9 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -65,18 +65,18 @@ export const gitlabHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - logger.warn(`[${requestId}] GitLab webhook secret not configured`) + logger.warn('GitLab webhook secret not configured', { requestId }) return new NextResponse('Unauthorized - Missing GitLab webhook secret', { status: 401 }) } const token = request.headers.get('X-Gitlab-Token') if (!token) { - logger.warn(`[${requestId}] GitLab webhook missing X-Gitlab-Token header`) + logger.warn('GitLab webhook missing X-Gitlab-Token header', { requestId }) return new NextResponse('Unauthorized - Missing GitLab token', { status: 401 }) } if (!safeCompare(token, secret)) { - logger.warn(`[${requestId}] GitLab token verification failed`) + logger.warn('GitLab token verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid GitLab token', { status: 401 }) } @@ -91,9 +91,9 @@ export const gitlabHandler: WebhookProviderHandler = { const { isGitLabEventMatch } = await import('@/triggers/gitlab/utils') if (!isGitLabEventMatch(triggerId, objectKind || '')) { - logger.debug( - `[${requestId}] GitLab event '${objectKind}' does not match trigger ${triggerId}, skipping` - ) + logger.debug(`GitLab event '${objectKind}' does not match trigger ${triggerId}, skipping`, { + requestId, + }) return false } return true @@ -221,7 +221,10 @@ export const gitlabHandler: WebhookProviderHandler = { if (!res.ok) { const detail = await res.text().catch(() => '') - logger.error(`[${ctx.requestId}] Failed to create GitLab webhook (${res.status})`, { detail }) + logger.error(`Failed to create GitLab webhook (${res.status})`, { + requestId: ctx.requestId, + detail, + }) if (res.status === 401) throw new Error( 'GitLab authentication failed. Verify your Personal Access Token has the api scope.' @@ -240,7 +243,9 @@ export const gitlabHandler: WebhookProviderHandler = { throw new Error('GitLab webhook created but no hook ID was returned.') } - logger.info(`[${ctx.requestId}] Created GitLab webhook ${created.id} for project ${projectId}`) + logger.info(`Created GitLab webhook ${created.id} for project ${projectId}`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } } }, @@ -257,9 +262,9 @@ export const gitlabHandler: WebhookProviderHandler = { if (!accessToken || !projectId || !externalId) { if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.') - logger.warn( - `[${ctx.requestId}] Skipping GitLab webhook cleanup — missing token, project, or hook ID` - ) + logger.warn('Skipping GitLab webhook cleanup — missing token, project, or hook ID', { + requestId: ctx.requestId, + }) return } @@ -270,9 +275,9 @@ export const gitlabHandler: WebhookProviderHandler = { if (ctx.strict) { throw new Error('Cannot delete GitLab webhook: the configured host is invalid.') } - logger.warn( - `[${ctx.requestId}] Skipping GitLab webhook cleanup — configured host is invalid` - ) + logger.warn('Skipping GitLab webhook cleanup — configured host is invalid', { + requestId: ctx.requestId, + }) return } throw error @@ -285,11 +290,11 @@ export const gitlabHandler: WebhookProviderHandler = { if (!res.ok && res.status !== 404) { if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`) - logger.warn( - `[${ctx.requestId}] Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}` - ) + logger.warn(`Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}`, { + requestId: ctx.requestId, + }) return } - logger.info(`[${ctx.requestId}] Deleted GitLab webhook ${externalId}`) + logger.info(`Deleted GitLab webhook ${externalId}`, { requestId: ctx.requestId }) }, } diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts index 11cbc50e21b..bc70d25b556 100644 --- a/apps/sim/lib/webhooks/providers/gmail.ts +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -27,21 +27,22 @@ export const gmailHandler: WebhookProviderHandler = { requestId, persistProviderConfig, }: PollingConfigContext) { - logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) + logger.info(`Setting up Gmail polling for webhook ${webhookData.id}`, { requestId }) try { const providerConfig = toRecord(webhookData.providerConfig) const credentialId = providerConfig.credentialId as string | undefined if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + logger.error(`Missing credentialId for Gmail webhook ${webhookData.id}`, { requestId }) return false } const resolvedGmail = await resolveOAuthAccountId(credentialId) if (!resolvedGmail) { logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` + `Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}`, + { requestId } ) return false } @@ -52,9 +53,9 @@ export const gmailHandler: WebhookProviderHandler = { .where(eq(account.id, resolvedGmail.accountId)) .limit(1) if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) + logger.error(`Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`, { + requestId, + }) return false } @@ -66,9 +67,9 @@ export const gmailHandler: WebhookProviderHandler = { requestId ) if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) + logger.error(`Failed to refresh/access Gmail token for credential ${credentialId}`, { + requestId, + }) return false } @@ -106,13 +107,14 @@ export const gmailHandler: WebhookProviderHandler = { .where(eq(webhook.id, webhookData.id as string)) } - logger.info( - `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` - ) + logger.info(`Successfully configured Gmail polling for webhook ${webhookData.id}`, { + requestId, + }) return true } catch (error: unknown) { const err = error as Error - logger.error(`[${requestId}] Failed to configure Gmail polling`, { + logger.error('Failed to configure Gmail polling', { + requestId, webhookId: webhookData.id, error: err.message, stack: err.stack, diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts index 609b595be90..ef97d4cbef1 100644 --- a/apps/sim/lib/webhooks/providers/gong.ts +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -66,7 +66,7 @@ export async function verifyGongJwtAuth(ctx: AuthContext): Promise | null const isVerificationRequest = !obj || Object.keys(obj).length === 0 || !obj.type if (isVerificationRequest) { - logger.info( - `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` - ) + logger.info('Grain reachability test detected - returning 200 for webhook verification', { + requestId, + }) return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified', @@ -301,7 +300,8 @@ export const grainHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { + logger.warn('Missing apiKey for Grain webhook creation.', { + requestId, webhookId: webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -315,7 +315,8 @@ export const grainHandler: WebhookProviderHandler = { GRAIN_V2_TRIGGER_TO_HOOK_TYPES[triggerId as keyof typeof GRAIN_V2_TRIGGER_TO_HOOK_TYPES] if (v2HookTypes) { const hookTypes = [...v2HookTypes] - logger.info(`[${requestId}] Creating Grain v2 hooks`, { + logger.info('Creating Grain v2 hooks', { + requestId, triggerId, hookTypes, webhookId: webhook.id, @@ -330,8 +331,8 @@ export const grainHandler: WebhookProviderHandler = { }) logger.info( - `[${requestId}] Successfully created ${externalIds.length} Grain hook(s) for webhook ${webhook.id}.`, - { externalIds, hookTypes } + `Successfully created ${externalIds.length} Grain hook(s) for webhook ${webhook.id}.`, + { requestId, externalIds, hookTypes } ) return { @@ -355,13 +356,11 @@ export const grainHandler: WebhookProviderHandler = { } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Grain webhook creation for webhook ${webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Grain webhook creation for webhook ${webhook.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -383,16 +382,17 @@ export const grainHandler: WebhookProviderHandler = { ) if (!apiKey) { - logger.warn( - `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) + logger.warn(`Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup`, { + requestId, + }) if (ctx.strict) throw new Error('Missing Grain apiKey for webhook deletion') return } if (externalIds.length === 0) { logger.warn( - `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` + `Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) throw new Error('Missing Grain externalId for webhook deletion') return @@ -406,9 +406,10 @@ export const grainHandler: WebhookProviderHandler = { } else { await deleteLegacyV1Hook({ apiKey, hookId: externalId, requestId }) } - logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) + logger.info(`Successfully deleted Grain webhook ${externalId}`, { requestId }) } catch (error) { - logger.warn(`[${requestId}] Failed to delete Grain webhook ${externalId} (non-fatal)`, { + logger.warn(`Failed to delete Grain webhook ${externalId} (non-fatal)`, { + requestId, error, }) failures.push(externalId) @@ -419,7 +420,7 @@ export const grainHandler: WebhookProviderHandler = { throw new Error(`Failed to delete ${failures.length} Grain webhook(s)`) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) + logger.warn('Error deleting Grain webhook (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/granola.ts b/apps/sim/lib/webhooks/providers/granola.ts index 70905343420..d44d0273b69 100644 --- a/apps/sim/lib/webhooks/providers/granola.ts +++ b/apps/sim/lib/webhooks/providers/granola.ts @@ -118,11 +118,12 @@ async function cleanupOrphanedGranolaEndpoint(params: { const { apiKey, endpointId, requestId } = params try { await deleteGranolaEndpoint(apiKey, endpointId) - logger.info(`[${requestId}] Removed orphaned Granola webhook endpoint ${endpointId}`) + logger.info(`Removed orphaned Granola webhook endpoint ${endpointId}`, { requestId }) } catch (error) { logger.error( - `[${requestId}] Failed to remove orphaned Granola webhook endpoint ${endpointId}; it may still be active`, - error + `Failed to remove orphaned Granola webhook endpoint ${endpointId}; it may still be active`, + error, + { requestId } ) } } @@ -141,7 +142,7 @@ export const granolaHandler: WebhookProviderHandler = { }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { - logger.warn(`[${requestId}] Granola webhook missing signing secret in provider configuration`) + logger.warn('Granola webhook missing signing secret in provider configuration', { requestId }) return new NextResponse('Unauthorized - Granola signing secret is required', { status: 401 }) } @@ -166,7 +167,8 @@ export const granolaHandler: WebhookProviderHandler = { if (!isGranolaEventMatch(triggerId, eventType)) { logger.debug( - `[${requestId}] Granola event mismatch for trigger ${triggerId}. event_type: ${eventType}. Skipping.` + `Granola event mismatch for trigger ${triggerId}. event_type: ${eventType}. Skipping.`, + { requestId } ) return false } @@ -213,7 +215,8 @@ export const granolaHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Granola webhook creation.`, { + logger.warn('Missing apiKey for Granola webhook creation.', { + requestId, webhookId: webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -232,7 +235,8 @@ export const granolaHandler: WebhookProviderHandler = { if (events.length > 0) requestBody.events = events if (folderIds.length > 0) requestBody.folder_ids = folderIds - logger.info(`[${requestId}] Creating Granola webhook endpoint`, { + logger.info('Creating Granola webhook endpoint', { + requestId, triggerId, events, scopes: requestBody.scopes, @@ -253,8 +257,8 @@ export const granolaHandler: WebhookProviderHandler = { if (!response.ok) { const errorBody = await response.text().catch(() => '') logger.error( - `[${requestId}] Failed to create Granola webhook endpoint for webhook ${webhook.id}. Status: ${response.status}`, - { response: errorBody } + `Failed to create Granola webhook endpoint for webhook ${webhook.id}. Status: ${response.status}`, + { requestId, response: errorBody } ) throw new Error(granolaUserFacingError(response.status, errorBody)) } @@ -266,7 +270,8 @@ export const granolaHandler: WebhookProviderHandler = { if (!created.id || !created.signing_secret) { logger.error( - `[${requestId}] Granola webhook endpoint response missing id or signing secret for webhook ${webhook.id}.` + `Granola webhook endpoint response missing id or signing secret for webhook ${webhook.id}.`, + { requestId } ) if (created.id) { await cleanupOrphanedGranolaEndpoint({ apiKey, endpointId: created.id, requestId }) @@ -276,10 +281,10 @@ export const granolaHandler: WebhookProviderHandler = { ) } - logger.info( - `[${requestId}] Successfully created Granola webhook endpoint for webhook ${webhook.id}.`, - { externalId: created.id } - ) + logger.info(`Successfully created Granola webhook endpoint for webhook ${webhook.id}.`, { + requestId, + externalId: created.id, + }) return { providerConfigUpdates: { @@ -304,7 +309,8 @@ export const granolaHandler: WebhookProviderHandler = { if (!apiKey || !externalId) { logger.warn( - `[${requestId}] Missing ${!apiKey ? 'apiKey' : 'externalId'} for Granola webhook deletion ${webhook.id}, skipping cleanup` + `Missing ${!apiKey ? 'apiKey' : 'externalId'} for Granola webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) { throw new Error( @@ -316,9 +322,9 @@ export const granolaHandler: WebhookProviderHandler = { await deleteGranolaEndpoint(apiKey, externalId) - logger.info(`[${requestId}] Successfully deleted Granola webhook endpoint ${externalId}`) + logger.info(`Successfully deleted Granola webhook endpoint ${externalId}`, { requestId }) } catch (error) { - logger.warn(`[${requestId}] Error deleting Granola webhook endpoint (non-fatal)`, error) + logger.warn('Error deleting Granola webhook endpoint (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts index f7d2c896fd2..864650782ca 100644 --- a/apps/sim/lib/webhooks/providers/greenhouse.ts +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -88,8 +88,9 @@ export const greenhouseHandler: WebhookProviderHandler = { const { isGreenhouseEventMatch } = await import('@/triggers/greenhouse/utils') if (!isGreenhouseEventMatch(triggerId, action || '')) { logger.debug( - `[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`, + `Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, diff --git a/apps/sim/lib/webhooks/providers/helpscout.ts b/apps/sim/lib/webhooks/providers/helpscout.ts index ef12e368c5f..89fcccd4ca2 100644 --- a/apps/sim/lib/webhooks/providers/helpscout.ts +++ b/apps/sim/lib/webhooks/providers/helpscout.ts @@ -147,7 +147,7 @@ export const helpScoutHandler: WebhookProviderHandler = { }) if (!response.ok) { const detail = await readHelpScoutErrorDetail(response) - logger.warn(`[${requestId}] Help Scout webhook creation failed`, { status: response.status }) + logger.warn('Help Scout webhook creation failed', { requestId, status: response.status }) const message = `Help Scout webhook creation failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}` if (response.status === 429 || response.status >= 500) throw new Error(message) throw new WebhookDeploymentConfigurationError( @@ -162,7 +162,7 @@ export const helpScoutHandler: WebhookProviderHandler = { 'Help Scout created the webhook but returned no webhook ID' ) } - logger.info(`[${requestId}] Created Help Scout webhook`, { externalId, events: events.length }) + logger.info('Created Help Scout webhook', { requestId, externalId, events: events.length }) return { providerConfigUpdates: { externalId, webhookSecret } } }, @@ -176,7 +176,7 @@ export const helpScoutHandler: WebhookProviderHandler = { // Creation always stores the ID, so a trigger without one (set up manually before Sim managed // Help Scout webhooks) has no subscription for Sim to delete. if (!externalId) { - logger.info(`[${requestId}] No managed Help Scout webhook to delete`) + logger.info('No managed Help Scout webhook to delete', { requestId }) return } if (parseIdText(externalId) === null) { @@ -199,7 +199,7 @@ export const helpScoutHandler: WebhookProviderHandler = { }) await response.body?.cancel().catch(() => {}) if (!response.ok && response.status !== 404) { - logger.warn(`[${requestId}] Help Scout webhook deletion failed`, { status: response.status }) + logger.warn('Help Scout webhook deletion failed', { requestId, status: response.status }) if (strict) throw new Error(`Help Scout webhook deletion failed: ${response.status}`) } }, @@ -232,7 +232,7 @@ export const helpScoutHandler: WebhookProviderHandler = { const eventType = request.headers.get(EVENT_HEADER)?.trim() ?? '' if (isHelpScoutEventMatch(triggerId, eventType)) return true - logger.debug(`[${requestId}] Help Scout event ${eventType} does not match ${triggerId}`) + logger.debug(`Help Scout event ${eventType} does not match ${triggerId}`, { requestId }) return false }, diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index 722bfa145b4..5ce2006ebcd 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -51,16 +51,16 @@ export const imapHandler: WebhookProviderHandler = { deploymentVersionId, persistProviderConfig, }: PollingConfigContext) { - logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) + logger.info(`Setting up IMAP polling for webhook ${webhookData.id}`, { requestId }) try { const providerConfig = toRecord(webhookData.providerConfig) const now = new Date() if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { - logger.error( - `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` - ) + logger.error(`Missing required IMAP connection settings for webhook ${webhookData.id}`, { + requestId, + }) return false } @@ -127,14 +127,12 @@ export const imapHandler: WebhookProviderHandler = { .where(eq(webhook.id, webhookData.id as string)) } - logger.info( - `[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}` - ) + logger.info(`Successfully configured IMAP polling for webhook ${webhookData.id}`, { + requestId, + }) return true } catch { - logger.error(`[${requestId}] Failed to configure IMAP polling`, { - webhookId: webhookData.id, - }) + logger.error('Failed to configure IMAP polling', { requestId, webhookId: webhookData.id }) return false } }, diff --git a/apps/sim/lib/webhooks/providers/incidentio.ts b/apps/sim/lib/webhooks/providers/incidentio.ts index 1f6acc5294a..afd6a782d12 100644 --- a/apps/sim/lib/webhooks/providers/incidentio.ts +++ b/apps/sim/lib/webhooks/providers/incidentio.ts @@ -44,9 +44,9 @@ export const incidentioHandler: WebhookProviderHandler = { }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { - logger.warn( - `[${requestId}] incident.io webhook missing signing secret in provider configuration` - ) + logger.warn('incident.io webhook missing signing secret in provider configuration', { + requestId, + }) return new NextResponse('Unauthorized - incident.io signing secret is required', { status: 401, }) @@ -75,7 +75,8 @@ export const incidentioHandler: WebhookProviderHandler = { if (!isIncidentioEventMatch(triggerId, eventType || '')) { logger.debug( - `[${requestId}] incident.io event mismatch for trigger ${triggerId}. event_type: ${eventType}. Skipping.` + `incident.io event mismatch for trigger ${triggerId}. event_type: ${eventType}. Skipping.`, + { requestId } ) return false } diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index 5b62fad2c42..142096bc963 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -26,12 +26,12 @@ export const instantlyHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { const secretToken = providerConfig.secretToken as string | undefined if (!secretToken) { - logger.warn(`[${requestId}] Instantly webhook secret token is missing`) + logger.warn('Instantly webhook secret token is missing', { requestId }) return new NextResponse('Unauthorized', { status: 401 }) } if (!verifyTokenAuth(request, secretToken, SIM_WEBHOOK_TOKEN_HEADER)) { - logger.warn(`[${requestId}] Unauthorized Instantly webhook request`) + logger.warn('Unauthorized Instantly webhook request', { requestId }) return new NextResponse('Unauthorized', { status: 401 }) } @@ -43,13 +43,14 @@ export const instantlyHandler: WebhookProviderHandler = { if (!triggerId) return true if (!isRecordLike(body)) { - logger.warn(`[${requestId}] Instantly webhook payload was not an object`) + logger.warn('Instantly webhook payload was not an object', { requestId }) return false } const { isInstantlyEventMatch } = await import('@/triggers/instantly/utils') if (!isInstantlyEventMatch(triggerId, body)) { - logger.info(`[${requestId}] Instantly event did not match trigger`, { + logger.info('Instantly event did not match trigger', { + requestId, triggerId, eventType: body.event_type, }) @@ -161,7 +162,8 @@ export const instantlyHandler: WebhookProviderHandler = { requestBody.campaign = campaignId } - logger.info(`[${requestId}] Creating Instantly webhook`, { + logger.info('Creating Instantly webhook', { + requestId, triggerId, eventType, hasCampaignId: Boolean(campaignId), @@ -180,7 +182,8 @@ export const instantlyHandler: WebhookProviderHandler = { const responseBody = await parseJsonResponse(response) if (!response.ok) { const message = extractInstantlyError(responseBody) - logger.error(`[${requestId}] Failed to create Instantly webhook`, { + logger.error('Failed to create Instantly webhook', { + requestId, status: response.status, message, response: responseBody, @@ -204,7 +207,8 @@ export const instantlyHandler: WebhookProviderHandler = { throw new Error('Instantly webhook was created but the API response did not include an ID.') } - logger.info(`[${requestId}] Successfully created Instantly webhook`, { + logger.info('Successfully created Instantly webhook', { + requestId, externalId, webhookId: webhook.id, }) @@ -221,7 +225,8 @@ export const instantlyHandler: WebhookProviderHandler = { const externalId = providerConfig.externalId as string | undefined if (!apiKey?.trim() || !externalId?.trim()) { - logger.warn(`[${requestId}] Missing Instantly webhook cleanup configuration`, { + logger.warn('Missing Instantly webhook cleanup configuration', { + requestId, webhookId: webhook.id, hasApiKey: Boolean(apiKey), hasExternalId: Boolean(externalId), @@ -242,7 +247,8 @@ export const instantlyHandler: WebhookProviderHandler = { if (!response.ok && response.status !== 404) { const responseBody = await parseJsonResponse(response) - logger.warn(`[${requestId}] Failed to delete Instantly webhook`, { + logger.warn('Failed to delete Instantly webhook', { + requestId, status: response.status, response: responseBody, }) @@ -251,12 +257,14 @@ export const instantlyHandler: WebhookProviderHandler = { } await response.body?.cancel() - logger.info(`[${requestId}] Successfully deleted Instantly webhook`, { + logger.info('Successfully deleted Instantly webhook', { + requestId, externalId, webhookId: webhook.id, }) } catch (error) { - logger.warn(`[${requestId}] Error deleting Instantly webhook`, { + logger.warn('Error deleting Instantly webhook', { + requestId, message: toError(error).message, }) if (ctx.strict) throw error diff --git a/apps/sim/lib/webhooks/providers/intercom.ts b/apps/sim/lib/webhooks/providers/intercom.ts index 61214df8cd4..ce8005b760b 100644 --- a/apps/sim/lib/webhooks/providers/intercom.ts +++ b/apps/sim/lib/webhooks/providers/intercom.ts @@ -54,12 +54,12 @@ export const intercomHandler: WebhookProviderHandler = { const signature = request.headers.get('X-Hub-Signature') if (!signature) { - logger.warn(`[${requestId}] Intercom webhook missing X-Hub-Signature header`) + logger.warn('Intercom webhook missing X-Hub-Signature header', { requestId }) return new NextResponse('Unauthorized - Missing Intercom signature', { status: 401 }) } if (!validateIntercomSignature(secret, signature, rawBody)) { - logger.warn(`[${requestId}] Intercom signature verification failed`) + logger.warn('Intercom signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Intercom signature', { status: 401 }) } @@ -69,9 +69,9 @@ export const intercomHandler: WebhookProviderHandler = { handleReachabilityTest(body: unknown, requestId: string) { const obj = body as Record | null if (obj?.topic === 'ping') { - logger.info( - `[${requestId}] Intercom ping event detected - returning 200 without triggering workflow` - ) + logger.info('Intercom ping event detected - returning 200 without triggering workflow', { + requestId, + }) return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified', @@ -93,12 +93,8 @@ export const intercomHandler: WebhookProviderHandler = { const { isIntercomEventMatch } = await import('@/triggers/intercom/utils') if (!isIntercomEventMatch(triggerId, topic || '')) { logger.debug( - `[${requestId}] Intercom event mismatch for trigger ${triggerId}. Topic: ${topic}. Skipping execution.`, - { - webhookId: webhook.id, - triggerId, - receivedTopic: topic, - } + `Intercom event mismatch for trigger ${triggerId}. Topic: ${topic}. Skipping execution.`, + { requestId, webhookId: webhook.id, triggerId, receivedTopic: topic } ) return false } diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts index fb9675f0d8e..2b115b59aa8 100644 --- a/apps/sim/lib/webhooks/providers/jira.ts +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -143,8 +143,9 @@ export const jiraHandler: WebhookProviderHandler = { const { isJiraEventMatch } = await import('@/triggers/jira/utils') if (!isJiraEventMatch(triggerId, webhookEvent || '', issueEventTypeName)) { logger.debug( - `[${requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, + `Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, @@ -161,8 +162,8 @@ export const jiraHandler: WebhookProviderHandler = { !matchesFieldFilters(obj, fieldFilters) ) { logger.debug( - `[${requestId}] Jira issue_updated field filter did not match for trigger ${triggerId}. Skipping execution.`, - { webhookId: webhook.id, workflowId: workflow.id, fieldFilters } + `Jira issue_updated field filter did not match for trigger ${triggerId}. Skipping execution.`, + { requestId, webhookId: webhook.id, workflowId: workflow.id, fieldFilters } ) return false } diff --git a/apps/sim/lib/webhooks/providers/jotform.ts b/apps/sim/lib/webhooks/providers/jotform.ts index 97ba5d5e1a9..4cb0011cbf3 100644 --- a/apps/sim/lib/webhooks/providers/jotform.ts +++ b/apps/sim/lib/webhooks/providers/jotform.ts @@ -167,7 +167,7 @@ export const jotformHandler: WebhookProviderHandler = { async createSubscription(ctx: SubscriptionContext): Promise { const credentials = readCredentials(ctx.webhook) if ('error' in credentials) { - logger.warn(`[${ctx.requestId}] ${credentials.error}`, { webhookId: ctx.webhook.id }) + logger.warn(credentials.error, { requestId: ctx.requestId, webhookId: ctx.webhook.id }) throw new WebhookDeploymentConfigurationError(credentials.error) } @@ -178,7 +178,8 @@ export const jotformHandler: WebhookProviderHandler = { already holds is not a no-op — it would leave the form delivering every submission twice. Redeploys re-run this, so the existing list decides whether to post. */ if (await listWebhookIdForUrl(credentials, notificationUrl)) { - logger.info(`[${ctx.requestId}] Jotform webhook was already registered`, { + logger.info('Jotform webhook was already registered', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, formId: credentials.formId, }) @@ -206,14 +207,16 @@ export const jotformHandler: WebhookProviderHandler = { ) } - logger.info(`[${ctx.requestId}] Registered Jotform webhook`, { + logger.info('Registered Jotform webhook', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, formId: credentials.formId, }) return {} } catch (error) { - logger.error(`[${ctx.requestId}] Failed to register Jotform webhook`, { + logger.error('Failed to register Jotform webhook', { + requestId: ctx.requestId, webhookId: ctx.webhook.id, error: getErrorMessage(error), }) @@ -224,9 +227,9 @@ export const jotformHandler: WebhookProviderHandler = { async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { const credentials = readCredentials(ctx.webhook) if ('error' in credentials) { - logger.warn( - `[${ctx.requestId}] Missing Jotform credentials, skipping webhook cleanup for ${ctx.webhook.id}` - ) + logger.warn(`Missing Jotform credentials, skipping webhook cleanup for ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw new Error(credentials.error) return } @@ -242,8 +245,8 @@ export const jotformHandler: WebhookProviderHandler = { ) ) { logger.info( - `[${ctx.requestId}] Skipping Jotform webhook deletion because an active deployment is served by the same callback`, - { webhookId: ctx.webhook.id } + 'Skipping Jotform webhook deletion because an active deployment is served by the same callback', + { requestId: ctx.requestId, webhookId: ctx.webhook.id } ) return } @@ -251,9 +254,9 @@ export const jotformHandler: WebhookProviderHandler = { const webhookId = await listWebhookIdForUrl(credentials, notificationUrl) if (!webhookId) { - logger.info( - `[${ctx.requestId}] Jotform webhook was already absent from form ${credentials.formId}` - ) + logger.info(`Jotform webhook was already absent from form ${credentials.formId}`, { + requestId: ctx.requestId, + }) return } @@ -266,9 +269,12 @@ export const jotformHandler: WebhookProviderHandler = { ) await parseJotformResponse(response, 'Jotform Delete Webhook') - logger.info(`[${ctx.requestId}] Deleted Jotform webhook from form ${credentials.formId}`) + logger.info(`Deleted Jotform webhook from form ${credentials.formId}`, { + requestId: ctx.requestId, + }) } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Jotform webhook (non-fatal)`, { + logger.warn('Error deleting Jotform webhook (non-fatal)', { + requestId: ctx.requestId, error: getErrorMessage(error), }) if (ctx.strict) throw error diff --git a/apps/sim/lib/webhooks/providers/jsm.ts b/apps/sim/lib/webhooks/providers/jsm.ts index c3cbc651525..7db920543c3 100644 --- a/apps/sim/lib/webhooks/providers/jsm.ts +++ b/apps/sim/lib/webhooks/providers/jsm.ts @@ -68,8 +68,9 @@ export const jsmHandler: WebhookProviderHandler = { const { isJsmEventMatch } = await import('@/triggers/jsm/utils') if (!isJsmEventMatch(triggerId, webhookEvent || '', issueEventTypeName, changelog)) { logger.debug( - `[${requestId}] JSM event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, + `JSM event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts index 46fdfab2d54..7c2befa3c85 100644 --- a/apps/sim/lib/webhooks/providers/lemlist.ts +++ b/apps/sim/lib/webhooks/providers/lemlist.ts @@ -21,7 +21,8 @@ export const lemlistHandler: WebhookProviderHandler = { const campaignId = providerConfig.campaignId as string | undefined if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { + logger.warn('Missing apiKey for Lemlist webhook creation.', { + requestId, webhookId: webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -45,7 +46,8 @@ export const lemlistHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(webhook) const authString = Buffer.from(`:${apiKey}`).toString('base64') - logger.info(`[${requestId}] Creating Lemlist webhook`, { + logger.info('Creating Lemlist webhook', { + requestId, triggerId, eventType, hasCampaignId: !!campaignId, @@ -83,8 +85,8 @@ export const lemlistHandler: WebhookProviderHandler = { (responseBody.error as string) || 'Unknown Lemlist API error' logger.error( - `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhook.id}. Status: ${lemlistResponse.status}`, - { message: errorMessage, response: responseBody } + `Failed to create webhook in Lemlist for webhook ${webhook.id}. Status: ${lemlistResponse.status}`, + { requestId, message: errorMessage, response: responseBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' @@ -100,24 +102,20 @@ export const lemlistHandler: WebhookProviderHandler = { throw new Error(userFriendlyMessage) } - logger.info( - `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhook.id}.`, - { - lemlistWebhookId: responseBody._id, - } - ) + logger.info(`Successfully created webhook in Lemlist for webhook ${webhook.id}.`, { + requestId, + lemlistWebhookId: responseBody._id, + }) return { providerConfigUpdates: { externalId: responseBody._id } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Lemlist webhook creation for webhook ${webhook.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -130,9 +128,9 @@ export const lemlistHandler: WebhookProviderHandler = { const externalId = config.externalId as string | undefined if (!apiKey) { - logger.warn( - `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` - ) + logger.warn(`Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup`, { + requestId, + }) if (ctx.strict) throw new Error('Missing Lemlist apiKey for webhook deletion') return } @@ -142,7 +140,8 @@ export const lemlistHandler: WebhookProviderHandler = { const deleteById = async (id: string) => { const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) if (!validation.isValid) { - logger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { + logger.warn('Invalid Lemlist hook ID format, skipping deletion', { + requestId, id: id.substring(0, 30), }) if (ctx.strict) throw new Error('Invalid Lemlist hook ID for deletion') @@ -159,15 +158,15 @@ export const lemlistHandler: WebhookProviderHandler = { if (!lemlistResponse.ok && lemlistResponse.status !== 404) { const responseBody = await lemlistResponse.json().catch(() => ({})) - logger.warn( - `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, { + requestId, + response: responseBody, + }) if (ctx.strict) { throw new Error(`Failed to delete Lemlist webhook: ${lemlistResponse.status}`) } } else { - logger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) + logger.info(`Successfully deleted Lemlist webhook ${id}`, { requestId }) } } @@ -178,8 +177,8 @@ export const lemlistHandler: WebhookProviderHandler = { if (ctx.strict) { logger.warn( - `[${requestId}] Missing Lemlist externalId during strict cleanup; skipping unsafe URL-based remote deletion`, - { webhookId: webhook.id } + 'Missing Lemlist externalId during strict cleanup; skipping unsafe URL-based remote deletion', + { requestId, webhookId: webhook.id } ) throw new Error('Missing Lemlist externalId for strict cleanup') } @@ -193,7 +192,8 @@ export const lemlistHandler: WebhookProviderHandler = { }) if (!listResponse.ok) { - logger.warn(`[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, { + logger.warn(`Failed to list Lemlist webhooks for cleanup ${webhook.id}`, { + requestId, status: listResponse.status, }) if (ctx.strict) throw new Error(`Failed to list Lemlist webhooks: ${listResponse.status}`) @@ -215,7 +215,8 @@ export const lemlistHandler: WebhookProviderHandler = { }) if (matches.length === 0) { - logger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { + logger.info(`Lemlist webhook not found for cleanup ${webhook.id}`, { + requestId, notificationUrl, }) return @@ -228,7 +229,7 @@ export const lemlistHandler: WebhookProviderHandler = { } } } catch (error) { - logger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + logger.warn('Error deleting Lemlist webhook (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 5b5d09a341b..c47e649a2ee 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -82,7 +82,7 @@ export const linearHandler: WebhookProviderHandler = { const parsed = JSON.parse(rawBody) as Record const ts = parsed.webhookTimestamp if (typeof ts !== 'number' || !Number.isFinite(ts)) { - logger.warn(`[${requestId}] Linear webhookTimestamp missing or invalid`) + logger.warn('Linear webhookTimestamp missing or invalid', { requestId }) return new NextResponse('Unauthorized - Invalid webhook timestamp', { status: 401, }) @@ -90,17 +90,17 @@ export const linearHandler: WebhookProviderHandler = { if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) { logger.warn( - `[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)` + `Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)`, + { requestId } ) return new NextResponse('Unauthorized - Webhook timestamp skew too large', { status: 401, }) } } catch (error) { - logger.warn( - `[${requestId}] Linear webhook body parse failed after signature verification`, - error - ) + logger.warn('Linear webhook body parse failed after signature verification', error, { + requestId, + }) return new NextResponse('Unauthorized - Invalid webhook body', { status: 401 }) } @@ -145,7 +145,8 @@ export const linearHandler: WebhookProviderHandler = { const type = typeof obj.type === 'string' ? obj.type : undefined if (!isLinearEventMatch(triggerId, type || '', action)) { logger.debug( - `[${requestId}] Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.` + `Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.`, + { requestId } ) return false } @@ -183,7 +184,9 @@ export const linearHandler: WebhookProviderHandler = { const apiKey = config.apiKey as string | undefined if (!apiKey) { - logger.warn(`[${ctx.requestId}] Missing API key for Linear webhook ${ctx.webhook.id}`) + logger.warn(`Missing API key for Linear webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) throw new WebhookDeploymentConfigurationError( 'Linear API key is required. Please provide a valid API key in the trigger configuration.' ) @@ -192,7 +195,7 @@ export const linearHandler: WebhookProviderHandler = { const { LINEAR_RESOURCE_TYPE_MAP } = await import('@/triggers/linear/utils') const resourceTypes = LINEAR_RESOURCE_TYPE_MAP[triggerId] if (!resourceTypes) { - logger.warn(`[${ctx.requestId}] Unknown Linear trigger ID: ${triggerId}`) + logger.warn(`Unknown Linear trigger ID: ${triggerId}`, { requestId: ctx.requestId }) throw new Error(`Unknown Linear trigger type: ${triggerId}`) } @@ -242,7 +245,8 @@ export const linearHandler: WebhookProviderHandler = { if (!result?.success) { const errors = data?.errors?.map((e: { message: string }) => e.message).join(', ') - logger.error(`[${ctx.requestId}] Failed to create Linear webhook`, { + logger.error('Failed to create Linear webhook', { + requestId: ctx.requestId, errors, webhookId: ctx.webhook.id, }) @@ -256,9 +260,9 @@ export const linearHandler: WebhookProviderHandler = { ) } - logger.info( - `[${ctx.requestId}] Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}` - ) + logger.info(`Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { @@ -270,7 +274,8 @@ export const linearHandler: WebhookProviderHandler = { if (error instanceof Error && error.message !== 'fetch failed') { throw error } - logger.error(`[${ctx.requestId}] Error creating Linear webhook`, { + logger.error('Error creating Linear webhook', { + requestId: ctx.requestId, error: toError(error).message, }) throw new Error('Failed to create Linear webhook. Please verify your API key and try again.') @@ -309,7 +314,8 @@ export const linearHandler: WebhookProviderHandler = { if (!response.ok) { logger.warn( - `[${ctx.requestId}] Linear API returned HTTP ${response.status} during webhook deletion for ${externalId}` + `Linear API returned HTTP ${response.status} during webhook deletion for ${externalId}`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error(`Linear webhook deletion failed: ${response.status}`) return @@ -317,25 +323,26 @@ export const linearHandler: WebhookProviderHandler = { const data = await response.json() if (data?.data?.webhookDelete?.success) { - logger.info( - `[${ctx.requestId}] Deleted Linear webhook ${externalId} for webhook ${ctx.webhook.id}` - ) + logger.info(`Deleted Linear webhook ${externalId} for webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) } else { const errorMessages = getGraphQLErrorMessages(data) if (errorMessages.some(isAlreadyAbsentWebhookMessage)) { - logger.info( - `[${ctx.requestId}] Linear webhook ${externalId} was already absent during deletion` - ) + logger.info(`Linear webhook ${externalId} was already absent during deletion`, { + requestId: ctx.requestId, + }) return } - logger.warn( - `[${ctx.requestId}] Linear webhook deletion returned unsuccessful for ${externalId}` - ) + logger.warn(`Linear webhook deletion returned unsuccessful for ${externalId}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw new Error('Linear webhook deletion returned unsuccessful') } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Linear webhook ${externalId} (non-fatal)`, { + logger.warn(`Error deleting Linear webhook ${externalId} (non-fatal)`, { + requestId: ctx.requestId, error: toError(error).message, }) if (ctx.strict) throw error diff --git a/apps/sim/lib/webhooks/providers/linq.ts b/apps/sim/lib/webhooks/providers/linq.ts index 5337dee92d3..6f05fd820d2 100644 --- a/apps/sim/lib/webhooks/providers/linq.ts +++ b/apps/sim/lib/webhooks/providers/linq.ts @@ -36,7 +36,7 @@ export const linqHandler: WebhookProviderHandler = { }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { - logger.warn(`[${requestId}] Linq webhook missing signing secret in provider configuration`) + logger.warn('Linq webhook missing signing secret in provider configuration', { requestId }) return new NextResponse('Unauthorized - Linq signing secret is required', { status: 401 }) } @@ -60,14 +60,15 @@ export const linqHandler: WebhookProviderHandler = { const expectedType = LINQ_TRIGGER_TO_EVENT_TYPE[triggerId] if (!expectedType) { - logger.debug(`[${requestId}] Unknown Linq triggerId ${triggerId}, skipping.`) + logger.debug(`Unknown Linq triggerId ${triggerId}, skipping.`, { requestId }) return false } const actualType = (body as Record)?.event_type as string | undefined if (actualType !== expectedType) { logger.debug( - `[${requestId}] Linq event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.` + `Linq event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`, + { requestId } ) return false } @@ -100,9 +101,7 @@ export const linqHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Linq webhook creation.`, { - webhookId: webhook.id, - }) + logger.warn('Missing apiKey for Linq webhook creation.', { requestId, webhookId: webhook.id }) throw new WebhookDeploymentConfigurationError( 'Linq API Key is required. Please provide your Linq API Key in the trigger configuration.' ) @@ -128,7 +127,8 @@ export const linqHandler: WebhookProviderHandler = { requestBody.phone_numbers = phoneNumbers } - logger.info(`[${requestId}] Creating Linq webhook subscription`, { + logger.info('Creating Linq webhook subscription', { + requestId, triggerId, events, webhookId: webhook.id, @@ -148,8 +148,8 @@ export const linqHandler: WebhookProviderHandler = { (responseBody.message as string) || 'Unknown Linq API error' logger.error( - `[${requestId}] Failed to create Linq webhook subscription for webhook ${webhook.id}. Status: ${response.status}`, - { message: errorMessage } + `Failed to create Linq webhook subscription for webhook ${webhook.id}. Status: ${response.status}`, + { requestId, message: errorMessage } ) if (response.status === 401 || response.status === 403) { @@ -170,7 +170,7 @@ export const linqHandler: WebhookProviderHandler = { ) } - logger.info(`[${requestId}] Successfully created Linq webhook subscription ${externalId}.`) + logger.info(`Successfully created Linq webhook subscription ${externalId}.`, { requestId }) return { providerConfigUpdates: { @@ -189,7 +189,8 @@ export const linqHandler: WebhookProviderHandler = { if (!apiKey || !externalId) { logger.warn( - `[${requestId}] Missing apiKey or externalId for Linq webhook deletion ${webhook.id}, skipping cleanup` + `Missing apiKey or externalId for Linq webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) throw new Error('Missing Linq webhook deletion credentials') return @@ -201,17 +202,17 @@ export const linqHandler: WebhookProviderHandler = { }) if (!response.ok && response.status !== 404) { - logger.warn( - `[${requestId}] Failed to delete Linq webhook subscription (non-fatal): ${response.status}` - ) + logger.warn(`Failed to delete Linq webhook subscription (non-fatal): ${response.status}`, { + requestId, + }) if (ctx.strict) { throw new Error(`Failed to delete Linq webhook subscription: ${response.status}`) } } else { - logger.info(`[${requestId}] Successfully deleted Linq webhook subscription ${externalId}`) + logger.info(`Successfully deleted Linq webhook subscription ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Linq webhook subscription (non-fatal)`, error) + logger.warn('Error deleting Linq webhook subscription (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/loops.ts b/apps/sim/lib/webhooks/providers/loops.ts index 756610de6f2..2c2cb44a108 100644 --- a/apps/sim/lib/webhooks/providers/loops.ts +++ b/apps/sim/lib/webhooks/providers/loops.ts @@ -20,7 +20,7 @@ export const loopsHandler: WebhookProviderHandler = { }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { - logger.warn(`[${requestId}] Loops webhook missing signing secret in provider configuration`) + logger.warn('Loops webhook missing signing secret in provider configuration', { requestId }) return new NextResponse('Unauthorized - Loops signing secret is required', { status: 401 }) } @@ -50,7 +50,8 @@ export const loopsHandler: WebhookProviderHandler = { if (!isLoopsEventMatch(triggerId, body as Record)) { const actualEvent = (body as Record)?.eventName logger.debug( - `[${requestId}] Loops event mismatch for trigger ${triggerId}. Got: ${String(actualEvent)}. Skipping.` + `Loops event mismatch for trigger ${triggerId}. Got: ${String(actualEvent)}. Skipping.`, + { requestId } ) return false } diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index 1d441b991b5..71858790543 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -97,7 +97,7 @@ async function fetchWithDNSPinning( try { const urlValidation = await validateUrlWithDNS(url, 'contentUrl', 'contentFetch') if (!urlValidation.isValid) { - logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url }) + logger.warn(`Invalid content URL: ${urlValidation.error}`, { requestId, url }) return null } const headers: Record = {} @@ -110,7 +110,8 @@ async function fetchWithDNSPinning( }) return response } catch (error) { - logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { + logger.error('Error fetching URL with DNS pinning', { + requestId, error: toError(error).message, url: sanitizeUrlForLog(url), }) @@ -483,7 +484,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const url = new URL(request.url) const validationToken = url.searchParams.get('validationToken') if (validationToken) { - logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) + logger.info(`Microsoft Graph subscription validation for path: ${path}`, { requestId }) return new NextResponse(validationToken, { status: 200, headers: { 'Content-Type': 'text/plain' }, @@ -496,9 +497,9 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { if (providerConfig.triggerId !== 'microsoftteams_chat_subscription') { const hmacSecret = providerConfig.hmacSecret as string | undefined if (!hmacSecret) { - logger.error( - `[${requestId}] Microsoft Teams outgoing webhook missing configured HMAC secret` - ) + logger.error('Microsoft Teams outgoing webhook missing configured HMAC secret', { + requestId, + }) return new NextResponse('Unauthorized - Missing HMAC secret configuration', { status: 401, }) @@ -507,14 +508,14 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const authHeader = request.headers.get('authorization') if (!authHeader || !authHeader.startsWith('HMAC ')) { - logger.warn( - `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` - ) + logger.warn('Microsoft Teams outgoing webhook missing HMAC authorization header', { + requestId, + }) return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 }) } if (!validateMicrosoftTeamsSignature(hmacSecret, authHeader, rawBody)) { - logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) + logger.warn('Microsoft Teams HMAC signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 }) } } @@ -523,7 +524,8 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const expectedClientState = String(webhook.id ?? '') if (!expectedClientState) { logger.warn( - `[${requestId}] Microsoft Teams chat subscription webhook missing id for clientState verification` + 'Microsoft Teams chat subscription webhook missing id for clientState verification', + { requestId } ) return new NextResponse('Unauthorized - Invalid clientState', { status: 401 }) } @@ -539,18 +541,18 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { } if (notifications.length === 0) { - logger.warn( - `[${requestId}] Microsoft Teams chat subscription notification missing value array` - ) + logger.warn('Microsoft Teams chat subscription notification missing value array', { + requestId, + }) return new NextResponse('Unauthorized - Invalid notification payload', { status: 401 }) } for (const notification of notifications) { const clientState = (notification as Record)?.clientState if (typeof clientState !== 'string' || !safeCompare(clientState, expectedClientState)) { - logger.warn( - `[${requestId}] Microsoft Teams chat subscription clientState verification failed` - ) + logger.warn('Microsoft Teams chat subscription clientState verification failed', { + requestId, + }) return new NextResponse('Unauthorized - Invalid clientState', { status: 401 }) } } @@ -603,14 +605,14 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const chatId = config.chatId as string | undefined if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`) + logger.warn(`Missing credentialId for Teams chat subscription ${webhook.id}`, { requestId }) throw new WebhookDeploymentConfigurationError( 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' ) } if (!chatId) { - logger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) + logger.warn(`Missing chatId for Teams chat subscription ${webhook.id}`, { requestId }) throw new WebhookDeploymentConfigurationError( 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' ) @@ -618,7 +620,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const accessToken = await getCredentialAccessToken(credentialId, requestId) if (!accessToken) { - logger.error(`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`) + logger.error(`Failed to get access token for Teams subscription ${webhook.id}`, { requestId }) throw new Error( 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' ) @@ -634,7 +636,8 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { if (checkRes.ok) { const existingPayload = await checkRes.json() logger.info( - `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` + `Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}`, + { requestId } ) return { providerConfigUpdates: { @@ -644,7 +647,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { } } } catch { - logger.debug(`[${requestId}] Existing subscription check failed, will create new one`) + logger.debug('Existing subscription check failed, will create new one', { requestId }) } } @@ -678,13 +681,11 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { if (!res.ok) { const errorMessage = payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' - logger.error( - `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, - { - status: res.status, - error: payload.error, - } - ) + logger.error(`Failed to create Teams subscription for webhook ${webhook.id}`, { + requestId, + status: res.status, + error: payload.error, + }) let userFriendlyMessage = 'Failed to create Teams subscription' if (res.status === 401 || res.status === 403) { @@ -701,7 +702,8 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { } logger.info( - `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` + `Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}`, + { requestId } ) return { providerConfigUpdates: { @@ -719,10 +721,9 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { throw error } - logger.error( - `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, - error - ) + logger.error(`Error creating Teams subscription for webhook ${webhook.id}`, error, { + requestId, + }) throw new Error( getErrorMessage(error, 'Failed to create Teams subscription. Please try again.') ) @@ -745,7 +746,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const credentialId = config.credentialId as string | undefined if (!externalSubscriptionId || !credentialId) { - logger.info(`[${requestId}] No external subscription to delete for webhook ${webhook.id}`) + logger.info(`No external subscription to delete for webhook ${webhook.id}`, { requestId }) if (strict) throw new Error('Missing Teams subscription cleanup configuration') return } @@ -753,7 +754,8 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const accessToken = await getCredentialAccessToken(credentialId, requestId) if (!accessToken) { logger.warn( - `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` + `Could not get access token to delete Teams subscription for webhook ${webhook.id}`, + { requestId } ) if (strict) throw new Error('Missing Teams access token for subscription deletion') return @@ -769,21 +771,22 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { if (res.ok || res.status === 404) { logger.info( - `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` + `Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`, + { requestId } ) } else { // Drain the unread body so the connection is released. await res.text() logger.warn( - `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` + `Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}`, + { requestId } ) if (strict) throw new Error(`Failed to delete Teams subscription: ${res.status}`) } } catch (error) { - logger.error( - `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, - error - ) + logger.error(`Error deleting Teams subscription for webhook ${webhook.id}`, error, { + requestId, + }) if (strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/monday.ts b/apps/sim/lib/webhooks/providers/monday.ts index 9b10e78d1cf..a70174d3253 100644 --- a/apps/sim/lib/webhooks/providers/monday.ts +++ b/apps/sim/lib/webhooks/providers/monday.ts @@ -73,14 +73,18 @@ export const mondayHandler: WebhookProviderHandler = { const boardId = config.boardId as string | undefined if (!triggerId) { - logger.warn(`[${ctx.requestId}] Missing triggerId for Monday webhook ${ctx.webhook.id}`) + logger.warn(`Missing triggerId for Monday webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) throw new WebhookDeploymentConfigurationError( 'Trigger type is required for Monday.com webhook creation.' ) } if (!boardId) { - logger.warn(`[${ctx.requestId}] Missing boardId for Monday webhook ${ctx.webhook.id}`) + logger.warn(`Missing boardId for Monday webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) throw new WebhookDeploymentConfigurationError( 'Board ID is required. Please provide a valid Monday.com board ID in the trigger configuration.' ) @@ -94,7 +98,7 @@ export const mondayHandler: WebhookProviderHandler = { const { MONDAY_EVENT_TYPE_MAP } = await import('@/triggers/monday/utils') const eventType = MONDAY_EVENT_TYPE_MAP[triggerId] if (!eventType) { - logger.warn(`[${ctx.requestId}] Unknown Monday trigger ID: ${triggerId}`) + logger.warn(`Unknown Monday trigger ID: ${triggerId}`, { requestId: ctx.requestId }) throw new Error(`Unknown Monday.com trigger type: ${triggerId}`) } @@ -121,7 +125,8 @@ export const mondayHandler: WebhookProviderHandler = { if (errors && errors.length > 0) { const errorMsg = errors.map((e) => e.message).join(', ') - logger.error(`[${ctx.requestId}] Failed to create Monday webhook`, { + logger.error('Failed to create Monday webhook', { + requestId: ctx.requestId, errors: errorMsg, webhookId: ctx.webhook.id, }) @@ -142,7 +147,8 @@ export const mondayHandler: WebhookProviderHandler = { const externalId = String(result.id) logger.info( - `[${ctx.requestId}] Created Monday webhook ${externalId} for webhook ${ctx.webhook.id} (event: ${eventType}, board: ${boardId})` + `Created Monday webhook ${externalId} for webhook ${ctx.webhook.id} (event: ${eventType}, board: ${boardId})`, + { requestId: ctx.requestId } ) return { @@ -154,7 +160,8 @@ export const mondayHandler: WebhookProviderHandler = { if (error instanceof Error && error.message !== 'fetch failed') { throw error } - logger.error(`[${ctx.requestId}] Error creating Monday webhook`, { + logger.error('Error creating Monday webhook', { + requestId: ctx.requestId, error: toError(error).message, }) throw new Error( @@ -178,9 +185,9 @@ export const mondayHandler: WebhookProviderHandler = { const externalIdValidation = validateMondayNumericId(externalId, 'webhookId') if (!externalIdValidation.isValid) { - logger.warn( - `[${ctx.requestId}] Invalid externalId format for Monday webhook deletion: ${externalId}` - ) + logger.warn(`Invalid externalId format for Monday webhook deletion: ${externalId}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw new Error('Invalid Monday externalId for webhook deletion') return } @@ -192,16 +199,17 @@ export const mondayHandler: WebhookProviderHandler = { accessToken = await getCredentialAccessToken(credentialId, ctx.requestId) } } catch (error) { - logger.warn( - `[${ctx.requestId}] Could not resolve credentials for Monday webhook deletion (non-fatal)`, - { error: toError(error).message } - ) + logger.warn('Could not resolve credentials for Monday webhook deletion (non-fatal)', { + requestId: ctx.requestId, + error: toError(error).message, + }) if (ctx.strict) throw error } if (!accessToken) { logger.warn( - `[${ctx.requestId}] No access token available for Monday webhook deletion ${externalId} (non-fatal)` + `No access token available for Monday webhook deletion ${externalId} (non-fatal)`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Monday access token for webhook deletion') return @@ -218,7 +226,8 @@ export const mondayHandler: WebhookProviderHandler = { if (!response.ok) { logger.warn( - `[${ctx.requestId}] Monday API returned HTTP ${response.status} during webhook deletion for ${externalId}` + `Monday API returned HTTP ${response.status} during webhook deletion for ${externalId}`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error(`Monday webhook deletion failed: ${response.status}`) return @@ -232,28 +241,31 @@ export const mondayHandler: WebhookProviderHandler = { data.error_message || 'Unknown error' if (isAlreadyAbsentWebhookMessage(errorMsg)) { - logger.info( - `[${ctx.requestId}] Monday webhook ${externalId} was already absent during deletion` - ) + logger.info(`Monday webhook ${externalId} was already absent during deletion`, { + requestId: ctx.requestId, + }) return } - logger.warn( - `[${ctx.requestId}] Monday webhook deletion GraphQL error for ${externalId}: ${errorMsg}` - ) + logger.warn(`Monday webhook deletion GraphQL error for ${externalId}: ${errorMsg}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw new Error(`Monday webhook deletion failed: ${errorMsg}`) return } if (data.data?.delete_webhook?.id) { - logger.info( - `[${ctx.requestId}] Deleted Monday webhook ${externalId} for webhook ${ctx.webhook.id}` - ) + logger.info(`Deleted Monday webhook ${externalId} for webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) } else { - logger.warn(`[${ctx.requestId}] Monday webhook deletion returned no data for ${externalId}`) + logger.warn(`Monday webhook deletion returned no data for ${externalId}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw new Error('Monday webhook deletion returned no data') } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Monday webhook ${externalId} (non-fatal)`, { + logger.warn(`Error deleting Monday webhook ${externalId} (non-fatal)`, { + requestId: ctx.requestId, error: toError(error).message, }) if (ctx.strict) throw error diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts index 730c1077d35..8b282ae1089 100644 --- a/apps/sim/lib/webhooks/providers/notion.ts +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -58,7 +58,7 @@ export const notionHandler: WebhookProviderHandler = { const verificationToken = obj?.verification_token if (typeof verificationToken === 'string' && verificationToken.length > 0) { - logger.info(`[${requestId}] Notion verification request detected - returning 200`) + logger.info('Notion verification request detected - returning 200', { requestId }) return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified', @@ -121,8 +121,9 @@ export const notionHandler: WebhookProviderHandler = { if (!isNotionPayloadMatch(triggerId, obj)) { const eventType = obj.type as string | undefined logger.debug( - `[${requestId}] Notion event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, + `Notion event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, diff --git a/apps/sim/lib/webhooks/providers/otter.ts b/apps/sim/lib/webhooks/providers/otter.ts index 5baa6d19551..98748279a1e 100644 --- a/apps/sim/lib/webhooks/providers/otter.ts +++ b/apps/sim/lib/webhooks/providers/otter.ts @@ -47,7 +47,7 @@ export const otterHandler: WebhookProviderHandler = { const { isOtterEventMatch } = await import('@/triggers/otter/utils') const { event } = readWebhookMeta(body) if (!isOtterEventMatch(triggerId, event)) { - logger.debug(`[${requestId}] Otter event mismatch for trigger ${triggerId}: ${event}`) + logger.debug(`Otter event mismatch for trigger ${triggerId}: ${event}`, { requestId }) return false } return true diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts index 4ac0da014fb..58c6374faa9 100644 --- a/apps/sim/lib/webhooks/providers/outlook.ts +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -27,21 +27,22 @@ export const outlookHandler: WebhookProviderHandler = { requestId, persistProviderConfig, }: PollingConfigContext) { - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) + logger.info(`Setting up Outlook polling for webhook ${webhookData.id}`, { requestId }) try { const providerConfig = toRecord(webhookData.providerConfig) const credentialId = providerConfig.credentialId as string | undefined if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + logger.error(`Missing credentialId for Outlook webhook ${webhookData.id}`, { requestId }) return false } const resolvedOutlook = await resolveOAuthAccountId(credentialId) if (!resolvedOutlook) { logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` + `Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}`, + { requestId } ) return false } @@ -52,9 +53,9 @@ export const outlookHandler: WebhookProviderHandler = { .where(eq(account.id, resolvedOutlook.accountId)) .limit(1) if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) + logger.error(`Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`, { + requestId, + }) return false } @@ -66,9 +67,9 @@ export const outlookHandler: WebhookProviderHandler = { requestId ) if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) + logger.error(`Failed to refresh/access Outlook token for credential ${credentialId}`, { + requestId, + }) return false } @@ -102,13 +103,14 @@ export const outlookHandler: WebhookProviderHandler = { .where(eq(webhook.id, webhookData.id as string)) } - logger.info( - `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` - ) + logger.info(`Successfully configured Outlook polling for webhook ${webhookData.id}`, { + requestId, + }) return true } catch (error: unknown) { const err = error as Error - logger.error(`[${requestId}] Failed to configure Outlook polling`, { + logger.error('Failed to configure Outlook polling', { + requestId, webhookId: webhookData.id, error: err.message, stack: err.stack, diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts index 9f3d318d4e9..abb9017a387 100644 --- a/apps/sim/lib/webhooks/providers/pagerduty.ts +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -100,9 +100,9 @@ export const pagerdutyHandler: WebhookProviderHandler = { const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils') if (!isPagerDutyEventMatch(triggerId, eventType || '')) { - logger.debug( - `[${requestId}] PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping` - ) + logger.debug(`PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping`, { + requestId, + }) return false } return true @@ -168,7 +168,8 @@ export const pagerdutyHandler: WebhookProviderHandler = { if (!res.ok) { const detail = await res.text().catch(() => '') - logger.error(`[${ctx.requestId}] Failed to create PagerDuty webhook (${res.status})`, { + logger.error(`Failed to create PagerDuty webhook (${res.status})`, { + requestId: ctx.requestId, detail, }) if (res.status === 401) @@ -193,7 +194,9 @@ export const pagerdutyHandler: WebhookProviderHandler = { throw new Error('PagerDuty webhook created but no signing secret was returned on creation.') } - logger.info(`[${ctx.requestId}] Created PagerDuty webhook subscription ${externalId}`) + logger.info(`Created PagerDuty webhook subscription ${externalId}`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { externalId, webhookSecret: secret } } }, @@ -204,9 +207,9 @@ export const pagerdutyHandler: WebhookProviderHandler = { if (!apiKey || !externalId) { if (ctx.strict) throw new Error('Missing PagerDuty API key or subscription ID for deletion.') - logger.warn( - `[${ctx.requestId}] Skipping PagerDuty webhook cleanup — missing API key or subscription ID` - ) + logger.warn('Skipping PagerDuty webhook cleanup — missing API key or subscription ID', { + requestId: ctx.requestId, + }) return } @@ -217,11 +220,13 @@ export const pagerdutyHandler: WebhookProviderHandler = { if (!res.ok && res.status !== 404) { if (ctx.strict) throw new Error(`Failed to delete PagerDuty webhook: ${res.status}`) - logger.warn( - `[${ctx.requestId}] Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}` - ) + logger.warn(`Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}`, { + requestId: ctx.requestId, + }) return } - logger.info(`[${ctx.requestId}] Deleted PagerDuty webhook subscription ${externalId}`) + logger.info(`Deleted PagerDuty webhook subscription ${externalId}`, { + requestId: ctx.requestId, + }) }, } diff --git a/apps/sim/lib/webhooks/providers/quickbooks.ts b/apps/sim/lib/webhooks/providers/quickbooks.ts index fe48a10ca45..0b948ab3432 100644 --- a/apps/sim/lib/webhooks/providers/quickbooks.ts +++ b/apps/sim/lib/webhooks/providers/quickbooks.ts @@ -33,7 +33,7 @@ export function verifyQuickBooksSignature( } function unauthorized(requestId: string, reason: string): NextResponse { - logger.warn(`[${requestId}] ${reason}`) + logger.warn(reason, { requestId }) return new NextResponse('Unauthorized', { status: 401 }) } diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index b3738f5464c..03523e15f40 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -156,7 +156,7 @@ const defaultHandler: WebhookProviderHandler = { const token = providerConfig.token if (typeof token === 'string') { if (!verifyTokenAuth(request, token)) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + logger.warn('Unauthorized webhook access attempt - invalid token', { requestId }) return new NextResponse('Unauthorized', { status: 401 }) } } diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index e532540123c..935911c6604 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -29,7 +29,7 @@ export const resendHandler: WebhookProviderHandler = { }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { - logger.warn(`[${requestId}] Resend webhook missing signing secret in provider configuration`) + logger.warn('Resend webhook missing signing secret in provider configuration', { requestId }) return new NextResponse('Unauthorized - Resend signing secret is required', { status: 401 }) } @@ -54,7 +54,7 @@ export const resendHandler: WebhookProviderHandler = { const expectedType = RESEND_TRIGGER_TO_EVENT_TYPE[triggerId] if (!expectedType) { - logger.debug(`[${requestId}] Unknown Resend triggerId ${triggerId}, skipping.`) + logger.debug(`Unknown Resend triggerId ${triggerId}, skipping.`, { requestId }) return false } @@ -62,7 +62,8 @@ export const resendHandler: WebhookProviderHandler = { if (actualType !== expectedType) { logger.debug( - `[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.` + `Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`, + { requestId } ) return false } @@ -115,7 +116,8 @@ export const resendHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!apiKey) { - logger.warn(`[${requestId}] Missing apiKey for Resend webhook creation.`, { + logger.warn('Missing apiKey for Resend webhook creation.', { + requestId, webhookId: webhook.id, }) throw new WebhookDeploymentConfigurationError( @@ -136,7 +138,8 @@ export const resendHandler: WebhookProviderHandler = { const notificationUrl = getNotificationUrl(webhook) - logger.info(`[${requestId}] Creating Resend webhook`, { + logger.info('Creating Resend webhook', { + requestId, triggerId, events, webhookId: webhook.id, @@ -162,8 +165,8 @@ export const resendHandler: WebhookProviderHandler = { (responseBody.name as string) || 'Unknown Resend API error' logger.error( - `[${requestId}] Failed to create webhook in Resend for webhook ${webhook.id}. Status: ${resendResponse.status}`, - { message: errorMessage, response: responseBody } + `Failed to create webhook in Resend for webhook ${webhook.id}. Status: ${resendResponse.status}`, + { requestId, message: errorMessage, response: responseBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Resend' @@ -190,12 +193,10 @@ export const resendHandler: WebhookProviderHandler = { ) } - logger.info( - `[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`, - { - resendWebhookId: externalId, - } - ) + logger.info(`Successfully created webhook in Resend for webhook ${webhook.id}.`, { + requestId, + resendWebhookId: externalId, + }) return { providerConfigUpdates: { @@ -206,13 +207,11 @@ export const resendHandler: WebhookProviderHandler = { } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Resend webhook creation for webhook ${webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Resend webhook creation for webhook ${webhook.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -226,7 +225,8 @@ export const resendHandler: WebhookProviderHandler = { if (!apiKey || !externalId) { logger.warn( - `[${requestId}] Missing apiKey or externalId for Resend webhook deletion ${webhook.id}, skipping cleanup` + `Missing apiKey or externalId for Resend webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) throw new Error('Missing Resend webhook deletion credentials') return @@ -241,16 +241,16 @@ export const resendHandler: WebhookProviderHandler = { if (!resendResponse.ok && resendResponse.status !== 404) { const responseBody = await resendResponse.json().catch(() => ({})) - logger.warn( - `[${requestId}] Failed to delete Resend webhook (non-fatal): ${resendResponse.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete Resend webhook (non-fatal): ${resendResponse.status}`, { + requestId, + response: responseBody, + }) if (ctx.strict) throw new Error(`Failed to delete Resend webhook: ${resendResponse.status}`) } else { - logger.info(`[${requestId}] Successfully deleted Resend webhook ${externalId}`) + logger.info(`Successfully deleted Resend webhook ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Resend webhook (non-fatal)`, error) + logger.warn('Error deleting Resend webhook (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/revenuecat.ts b/apps/sim/lib/webhooks/providers/revenuecat.ts index 14ff48b7010..ffbca9b15ec 100644 --- a/apps/sim/lib/webhooks/providers/revenuecat.ts +++ b/apps/sim/lib/webhooks/providers/revenuecat.ts @@ -36,9 +36,9 @@ export const revenueCatHandler: WebhookProviderHandler = { const secret = providerConfig.authHeaderSecret as string | undefined if (!secret) { - logger.warn( - `[${requestId}] RevenueCat webhook missing Authorization secret in provider configuration` - ) + logger.warn('RevenueCat webhook missing Authorization secret in provider configuration', { + requestId, + }) return new NextResponse('Unauthorized - RevenueCat Authorization secret is required', { status: 401, }) @@ -46,14 +46,14 @@ export const revenueCatHandler: WebhookProviderHandler = { const authHeader = request.headers.get('authorization') if (!authHeader) { - logger.warn(`[${requestId}] RevenueCat webhook missing Authorization header`) + logger.warn('RevenueCat webhook missing Authorization header', { requestId }) return new NextResponse('Unauthorized - Missing RevenueCat Authorization header', { status: 401, }) } if (!safeCompare(authHeader, secret)) { - logger.warn(`[${requestId}] RevenueCat Authorization header verification failed`) + logger.warn('RevenueCat Authorization header verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid RevenueCat Authorization header', { status: 401, }) @@ -124,8 +124,8 @@ export const revenueCatHandler: WebhookProviderHandler = { if (!response.ok) { const errorBody = (await response.json().catch(() => ({}))) as Record logger.error( - `[${ctx.requestId}] Failed to create RevenueCat webhook for webhook ${ctx.webhook.id}. Status: ${response.status}`, - { response: errorBody } + `Failed to create RevenueCat webhook for webhook ${ctx.webhook.id}. Status: ${response.status}`, + { requestId: ctx.requestId, response: errorBody } ) let message = 'Failed to create webhook integration in RevenueCat' @@ -147,14 +147,15 @@ export const revenueCatHandler: WebhookProviderHandler = { if (!integrationId) { logger.error( - `[${ctx.requestId}] RevenueCat webhook created but no integration id was returned for webhook ${ctx.webhook.id}`, - { response: responseBody } + `RevenueCat webhook created but no integration id was returned for webhook ${ctx.webhook.id}`, + { requestId: ctx.requestId, response: responseBody } ) throw new Error('RevenueCat webhook creation succeeded but no integration id was returned') } logger.info( - `[${ctx.requestId}] Created RevenueCat webhook integration ${integrationId} for webhook ${ctx.webhook.id}` + `Created RevenueCat webhook integration ${integrationId} for webhook ${ctx.webhook.id}`, + { requestId: ctx.requestId } ) return { providerConfigUpdates: { externalId: integrationId, authHeaderSecret } } @@ -179,7 +180,8 @@ export const revenueCatHandler: WebhookProviderHandler = { if (!apiKey || !projectId || !externalId) { logger.warn( - `[${ctx.requestId}] Missing apiKey/projectId/externalId for RevenueCat webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing apiKey/projectId/externalId for RevenueCat webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing RevenueCat credentials for webhook deletion') return @@ -195,18 +197,22 @@ export const revenueCatHandler: WebhookProviderHandler = { if (!response.ok && response.status !== 404) { const errorBody = await response.json().catch(() => ({})) - logger.warn( - `[${ctx.requestId}] Failed to delete RevenueCat webhook (non-fatal): ${response.status}`, - { response: errorBody } - ) + logger.warn(`Failed to delete RevenueCat webhook (non-fatal): ${response.status}`, { + requestId: ctx.requestId, + response: errorBody, + }) if (ctx.strict) { throw new Error(`Failed to delete RevenueCat webhook: ${response.status}`) } } else { - logger.info(`[${ctx.requestId}] Deleted RevenueCat webhook integration ${externalId}`) + logger.info(`Deleted RevenueCat webhook integration ${externalId}`, { + requestId: ctx.requestId, + }) } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting RevenueCat webhook (non-fatal)`, error) + logger.warn('Error deleting RevenueCat webhook (non-fatal)', error, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, @@ -221,7 +227,8 @@ export const revenueCatHandler: WebhookProviderHandler = { if (!isRevenueCatEventMatch(triggerId, (body as Record) || {})) { const event = (body as Record)?.event as Record | undefined logger.debug( - `[${requestId}] RevenueCat event type '${event?.type as string | undefined}' does not match trigger ${triggerId}, skipping` + `RevenueCat event type '${event?.type as string | undefined}' does not match trigger ${triggerId}, skipping`, + { requestId } ) return false } diff --git a/apps/sim/lib/webhooks/providers/rootly.ts b/apps/sim/lib/webhooks/providers/rootly.ts index 79bbc026f8a..cd1ea420d80 100644 --- a/apps/sim/lib/webhooks/providers/rootly.ts +++ b/apps/sim/lib/webhooks/providers/rootly.ts @@ -68,34 +68,34 @@ export const rootlyHandler: WebhookProviderHandler = { }: AuthContext): Promise { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - logger.warn(`[${requestId}] Rootly webhook missing signing secret in provider configuration`) + logger.warn('Rootly webhook missing signing secret in provider configuration', { requestId }) return new NextResponse('Unauthorized - Rootly signing secret is required', { status: 401 }) } const header = request.headers.get('X-Rootly-Signature') if (!header) { - logger.warn(`[${requestId}] Rootly webhook missing signature header`) + logger.warn('Rootly webhook missing signature header', { requestId }) return new NextResponse('Unauthorized - Missing Rootly signature', { status: 401 }) } const parsed = parseRootlySignatureHeader(header) if (!parsed) { - logger.warn(`[${requestId}] Rootly signature header malformed`) + logger.warn('Rootly signature header malformed', { requestId }) return new NextResponse('Unauthorized - Malformed Rootly signature', { status: 401 }) } if (!validateRootlySignature(secret, parsed.timestamp, parsed.signature, rawBody)) { - logger.warn(`[${requestId}] Rootly signature verification failed`) + logger.warn('Rootly signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Rootly signature', { status: 401 }) } const tsSeconds = Number(parsed.timestamp) if (!Number.isFinite(tsSeconds)) { - logger.warn(`[${requestId}] Rootly signature timestamp invalid`) + logger.warn('Rootly signature timestamp invalid', { requestId }) return new NextResponse('Unauthorized - Invalid Rootly timestamp', { status: 401 }) } if (Math.abs(Date.now() - tsSeconds * 1000) > ROOTLY_WEBHOOK_TIMESTAMP_SKEW_MS) { - logger.warn(`[${requestId}] Rootly signature timestamp outside allowed skew`) + logger.warn('Rootly signature timestamp outside allowed skew', { requestId }) return new NextResponse('Unauthorized - Rootly timestamp skew too large', { status: 401 }) } @@ -110,7 +110,8 @@ export const rootlyHandler: WebhookProviderHandler = { const eventType = typeof event?.type === 'string' ? event.type : '' if (!isRootlyEventMatch(triggerId, eventType)) { logger.debug( - `[${requestId}] Rootly event mismatch for trigger ${triggerId}. Type: ${eventType}. Skipping.` + `Rootly event mismatch for trigger ${triggerId}. Type: ${eventType}. Skipping.`, + { requestId } ) return false } @@ -156,7 +157,8 @@ export const rootlyHandler: WebhookProviderHandler = { const signingSecret = generateId() - logger.info(`[${ctx.requestId}] Creating Rootly webhook endpoint`, { + logger.info('Creating Rootly webhook endpoint', { + requestId: ctx.requestId, triggerId, eventTypes, webhookId: ctx.webhook.id, @@ -205,8 +207,8 @@ export const rootlyHandler: WebhookProviderHandler = { } logger.error( - `[${ctx.requestId}] Failed to create Rootly webhook endpoint for webhook ${ctx.webhook.id}. Status: ${response.status}`, - { response: responseBody } + `Failed to create Rootly webhook endpoint for webhook ${ctx.webhook.id}. Status: ${response.status}`, + { requestId: ctx.requestId, response: responseBody } ) throw new Error(userFriendlyMessage) } @@ -218,19 +220,18 @@ export const rootlyHandler: WebhookProviderHandler = { } logger.info( - `[${ctx.requestId}] Successfully created Rootly webhook endpoint ${externalId} for webhook ${ctx.webhook.id}` + `Successfully created Rootly webhook endpoint ${externalId} for webhook ${ctx.webhook.id}`, + { requestId: ctx.requestId } ) return { providerConfigUpdates: { externalId, webhookSecret: signingSecret } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${ctx.requestId}] Exception during Rootly webhook creation for webhook ${ctx.webhook.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Rootly webhook creation for webhook ${ctx.webhook.id}.`, { + requestId: ctx.requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -243,7 +244,8 @@ export const rootlyHandler: WebhookProviderHandler = { if (!apiKey) { logger.warn( - `[${ctx.requestId}] Missing apiKey for Rootly webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing apiKey for Rootly webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Rootly apiKey for webhook deletion') return @@ -251,7 +253,8 @@ export const rootlyHandler: WebhookProviderHandler = { if (!externalId) { logger.warn( - `[${ctx.requestId}] Missing externalId for Rootly webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing externalId for Rootly webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Rootly externalId for webhook deletion') return @@ -267,21 +270,21 @@ export const rootlyHandler: WebhookProviderHandler = { if (response.ok || response.status === 404) { await response.body?.cancel() - logger.info( - `[${ctx.requestId}] Deleted Rootly webhook endpoint ${externalId} (status ${response.status})` - ) + logger.info(`Deleted Rootly webhook endpoint ${externalId} (status ${response.status})`, { + requestId: ctx.requestId, + }) } else { const responseBody = await response.json().catch(() => ({})) - logger.warn( - `[${ctx.requestId}] Failed to delete Rootly webhook endpoint (non-fatal): ${response.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete Rootly webhook endpoint (non-fatal): ${response.status}`, { + requestId: ctx.requestId, + response: responseBody, + }) if (ctx.strict) { throw new Error(`Failed to delete Rootly webhook endpoint: ${response.status}`) } } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Rootly webhook (non-fatal)`, error) + logger.warn('Error deleting Rootly webhook (non-fatal)', error, { requestId: ctx.requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts index f89720bd871..00c40925264 100644 --- a/apps/sim/lib/webhooks/providers/rss.ts +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -35,7 +35,7 @@ export const rssHandler: WebhookProviderHandler = { requestId, persistProviderConfig, }: PollingConfigContext) { - logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) + logger.info(`Setting up RSS polling for webhook ${webhookData.id}`, { requestId }) try { const providerConfig = toRecord(webhookData.providerConfig) @@ -56,13 +56,14 @@ export const rssHandler: WebhookProviderHandler = { .where(eq(webhook.id, webhookData.id as string)) } - logger.info( - `[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}` - ) + logger.info(`Successfully configured RSS polling for webhook ${webhookData.id}`, { + requestId, + }) return true } catch (error: unknown) { const err = error as Error - logger.error(`[${requestId}] Failed to configure RSS polling`, { + logger.error('Failed to configure RSS polling', { + requestId, webhookId: webhookData.id, error: err.message, }) diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 0331da2d260..5fccb69c480 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -80,12 +80,12 @@ export const salesforceHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { const secret = providerConfig.webhookSecret as string | undefined if (!secret?.trim()) { - logger.warn(`[${requestId}] Salesforce webhook missing webhookSecret — rejecting`) + logger.warn('Salesforce webhook missing webhookSecret — rejecting', { requestId }) return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 }) } if (!verifySalesforceSharedSecret(request, secret.trim())) { - logger.warn(`[${requestId}] Salesforce webhook secret verification failed`) + logger.warn('Salesforce webhook secret verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 }) } @@ -103,10 +103,12 @@ export const salesforceHandler: WebhookProviderHandler = { const obj = toRecord(body) if (!isSalesforceEventMatch(triggerId, obj, configuredObjectType)) { - logger.debug( - `[${requestId}] Salesforce event mismatch for trigger ${triggerId}. Skipping execution.`, - { webhookId: webhook.id, workflowId: workflow.id, triggerId } - ) + logger.debug(`Salesforce event mismatch for trigger ${triggerId}. Skipping execution.`, { + requestId, + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + }) return false } diff --git a/apps/sim/lib/webhooks/providers/sendblue.ts b/apps/sim/lib/webhooks/providers/sendblue.ts index 63ca68e52e6..3b7f7269677 100644 --- a/apps/sim/lib/webhooks/providers/sendblue.ts +++ b/apps/sim/lib/webhooks/providers/sendblue.ts @@ -41,14 +41,14 @@ export const sendblueHandler: WebhookProviderHandler = { if (!triggerId || !(triggerId in SENDBLUE_TRIGGER_IS_OUTBOUND)) return true if (!isRecordLike(body)) { - logger.warn(`[${requestId}] Sendblue webhook payload was not an object`) + logger.warn('Sendblue webhook payload was not an object', { requestId }) return false } const expected = SENDBLUE_TRIGGER_IS_OUTBOUND[triggerId] const isOutbound = body.is_outbound === true if (isOutbound !== expected) { - logger.info(`[${requestId}] Sendblue event did not match trigger`, { triggerId, isOutbound }) + logger.info('Sendblue event did not match trigger', { requestId, triggerId, isOutbound }) return false } diff --git a/apps/sim/lib/webhooks/providers/sentry.ts b/apps/sim/lib/webhooks/providers/sentry.ts index ab08586967e..769f4b1e9c7 100644 --- a/apps/sim/lib/webhooks/providers/sentry.ts +++ b/apps/sim/lib/webhooks/providers/sentry.ts @@ -64,7 +64,8 @@ export const sentryHandler: WebhookProviderHandler = { const { isSentryEventMatch } = await import('@/triggers/sentry/utils') if (!isSentryEventMatch(triggerId, resource, action)) { logger.debug( - `[${requestId}] Sentry event mismatch for trigger ${triggerId}. Resource: ${resource}, Action: ${action}. Skipping.` + `Sentry event mismatch for trigger ${triggerId}. Resource: ${resource}, Action: ${action}. Skipping.`, + { requestId } ) return false } diff --git a/apps/sim/lib/webhooks/providers/servicenow.ts b/apps/sim/lib/webhooks/providers/servicenow.ts index 43c79e4e159..83381812c24 100644 --- a/apps/sim/lib/webhooks/providers/servicenow.ts +++ b/apps/sim/lib/webhooks/providers/servicenow.ts @@ -14,7 +14,7 @@ export const servicenowHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { const secret = providerConfig.webhookSecret as string | undefined if (!secret?.trim()) { - logger.warn(`[${requestId}] ServiceNow webhook missing webhookSecret — rejecting`) + logger.warn('ServiceNow webhook missing webhookSecret — rejecting', { requestId }) return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 }) } @@ -22,7 +22,7 @@ export const servicenowHandler: WebhookProviderHandler = { !verifyTokenAuth(request, secret.trim(), 'x-sim-webhook-secret') && !verifyTokenAuth(request, secret.trim()) ) { - logger.warn(`[${requestId}] ServiceNow webhook secret verification failed`) + logger.warn('ServiceNow webhook secret verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 }) } @@ -40,10 +40,12 @@ export const servicenowHandler: WebhookProviderHandler = { const obj = toRecord(body) if (!isServiceNowEventMatch(triggerId, obj, configuredTableName)) { - logger.debug( - `[${requestId}] ServiceNow event mismatch for trigger ${triggerId}. Skipping execution.`, - { webhookId: webhook.id, workflowId: workflow.id, triggerId } - ) + logger.debug(`ServiceNow event mismatch for trigger ${triggerId}. Skipping execution.`, { + requestId, + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + }) return false } diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 90b6db22a74..b103879edc0 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -591,24 +591,24 @@ export function verifySlackRequestSignature( const timestamp = request.headers.get('x-slack-request-timestamp') if (!signature || !timestamp) { - logger.warn(`[${requestId}] Slack webhook missing signature or timestamp header`) + logger.warn('Slack webhook missing signature or timestamp header', { requestId }) return new NextResponse('Unauthorized - Missing Slack signature', { status: 401 }) } const now = Math.floor(Date.now() / 1000) const parsedTimestamp = Number(timestamp) if (Number.isNaN(parsedTimestamp)) { - logger.warn(`[${requestId}] Slack webhook timestamp is not a valid number`, { timestamp }) + logger.warn('Slack webhook timestamp is not a valid number', { requestId, timestamp }) return new NextResponse('Unauthorized - Invalid timestamp', { status: 401 }) } const skew = Math.abs(now - parsedTimestamp) if (skew > SLACK_TIMESTAMP_MAX_SKEW) { - logger.warn(`[${requestId}] Slack webhook timestamp too old`, { timestamp, now, skew }) + logger.warn('Slack webhook timestamp too old', { requestId, timestamp, now, skew }) return new NextResponse('Unauthorized - Request timestamp too old', { status: 401 }) } if (!validateSlackSignature(signingSecret, signature, timestamp, rawBody)) { - logger.warn(`[${requestId}] Slack signature verification failed`) + logger.warn('Slack signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) } @@ -880,7 +880,7 @@ export const slackHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret) { - logger.warn(`[${requestId}] Slack webhook signing secret not configured`) + logger.warn('Slack webhook signing secret not configured', { requestId }) return new NextResponse('Unauthorized - Missing Slack signing secret', { status: 401 }) } return verifySlackRequestSignature(signingSecret, request, rawBody, requestId) diff --git a/apps/sim/lib/webhooks/providers/stripe.ts b/apps/sim/lib/webhooks/providers/stripe.ts index fb488a23999..49a4f9b57fa 100644 --- a/apps/sim/lib/webhooks/providers/stripe.ts +++ b/apps/sim/lib/webhooks/providers/stripe.ts @@ -18,22 +18,23 @@ export const stripeHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - logger.warn( - `[${requestId}] Stripe webhook missing webhookSecret in providerConfig — rejecting request` - ) + logger.warn('Stripe webhook missing webhookSecret in providerConfig — rejecting request', { + requestId, + }) return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 }) } const signature = request.headers.get('stripe-signature') if (!signature) { - logger.warn(`[${requestId}] Stripe webhook missing Stripe-Signature header`) + logger.warn('Stripe webhook missing Stripe-Signature header', { requestId }) return new NextResponse('Unauthorized - Missing Stripe signature', { status: 401 }) } try { Stripe.webhooks.constructEvent(rawBody, signature, secret) } catch (error) { - logger.warn(`[${requestId}] Stripe signature verification failed`, { + logger.warn('Stripe signature verification failed', { + requestId, error: getErrorMessage(error), }) return new NextResponse('Unauthorized - Invalid Stripe signature', { status: 401 }) diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts index afd30d9fa93..778ec28330f 100644 --- a/apps/sim/lib/webhooks/providers/telegram.ts +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -49,7 +49,7 @@ export const telegramHandler: WebhookProviderHandler = { if (!secretToken) return null if (!verifyTokenAuth(request, secretToken, TELEGRAM_SECRET_TOKEN_HEADER)) { - logger.warn(`[${requestId}] Rejected Telegram webhook request with invalid secret token`) + logger.warn('Rejected Telegram webhook request with invalid secret token', { requestId }) return new NextResponse('Unauthorized', { status: 401 }) } @@ -147,7 +147,9 @@ export const telegramHandler: WebhookProviderHandler = { const botToken = config.botToken as string | undefined if (!botToken) { - logger.warn(`[${ctx.requestId}] Missing botToken for Telegram webhook ${ctx.webhook.id}`) + logger.warn(`Missing botToken for Telegram webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) throw new WebhookDeploymentConfigurationError( 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' ) @@ -172,7 +174,7 @@ export const telegramHandler: WebhookProviderHandler = { const errorMessage = responseBody.description || `Failed to create Telegram webhook. Status: ${telegramResponse.status}` - logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + logger.error(errorMessage, { requestId: ctx.requestId, response: responseBody }) let userFriendlyMessage = 'Failed to create Telegram webhook' if (telegramResponse.status === 401) { @@ -185,9 +187,9 @@ export const telegramHandler: WebhookProviderHandler = { throw new Error(userFriendlyMessage) } - logger.info( - `[${ctx.requestId}] Successfully created Telegram webhook for webhook ${ctx.webhook.id}` - ) + logger.info(`Successfully created Telegram webhook for webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) return { providerConfigUpdates: { secretToken } } } catch (error: unknown) { if ( @@ -197,10 +199,9 @@ export const telegramHandler: WebhookProviderHandler = { throw error } - logger.error( - `[${ctx.requestId}] Error creating Telegram webhook for webhook ${ctx.webhook.id}`, - error - ) + logger.error(`Error creating Telegram webhook for webhook ${ctx.webhook.id}`, error, { + requestId: ctx.requestId, + }) throw new Error( getErrorMessage(error, 'Failed to create Telegram webhook. Please try again.') ) @@ -213,9 +214,9 @@ export const telegramHandler: WebhookProviderHandler = { const botToken = config.botToken as string | undefined if (!botToken) { - logger.warn( - `[${ctx.requestId}] Missing botToken for Telegram webhook deletion ${ctx.webhook.id}` - ) + logger.warn(`Missing botToken for Telegram webhook deletion ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) if (ctx.strict) throw new Error('Missing Telegram botToken for webhook deletion') return } @@ -228,8 +229,8 @@ export const telegramHandler: WebhookProviderHandler = { ) if (activeConfigs.length > 0) { logger.info( - `[${ctx.requestId}] Skipping Telegram webhook deletion because an active deployment uses the same bot token`, - { webhookId: ctx.webhook.id } + 'Skipping Telegram webhook deletion because an active deployment uses the same bot token', + { requestId: ctx.requestId, webhookId: ctx.webhook.id } ) return } @@ -245,18 +246,17 @@ export const telegramHandler: WebhookProviderHandler = { const errorMessage = responseBody.description || `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` - logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + logger.error(errorMessage, { requestId: ctx.requestId, response: responseBody }) if (ctx.strict) throw new Error(errorMessage) } else { - logger.info( - `[${ctx.requestId}] Successfully deleted Telegram webhook for webhook ${ctx.webhook.id}` - ) + logger.info(`Successfully deleted Telegram webhook for webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) } } catch (error) { - logger.error( - `[${ctx.requestId}] Error deleting Telegram webhook for webhook ${ctx.webhook.id}`, - error - ) + logger.error(`Error deleting Telegram webhook for webhook ${ctx.webhook.id}`, error, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/tiktok.ts b/apps/sim/lib/webhooks/providers/tiktok.ts index a3aed74ab05..a5ac7d354e7 100644 --- a/apps/sim/lib/webhooks/providers/tiktok.ts +++ b/apps/sim/lib/webhooks/providers/tiktok.ts @@ -57,24 +57,25 @@ export function verifyTikTokSignature( nowSeconds: number = Math.floor(Date.now() / 1000) ): NextResponse | null { if (!clientSecret) { - logger.warn(`[${requestId}] TikTok webhook missing TIKTOK_CLIENT_SECRET`) + logger.warn('TikTok webhook missing TIKTOK_CLIENT_SECRET', { requestId }) return new NextResponse('Unauthorized - TikTok client secret not configured', { status: 401 }) } const parts = parseTikTokSignatureHeader(signatureHeader) if (!parts) { - logger.warn(`[${requestId}] TikTok webhook missing or malformed TikTok-Signature header`) + logger.warn('TikTok webhook missing or malformed TikTok-Signature header', { requestId }) return new NextResponse('Unauthorized - Missing TikTok signature', { status: 401 }) } const timestampSeconds = Number(parts.timestamp) if (!Number.isFinite(timestampSeconds)) { - logger.warn(`[${requestId}] TikTok webhook signature timestamp is not a number`) + logger.warn('TikTok webhook signature timestamp is not a number', { requestId }) return new NextResponse('Unauthorized - Invalid TikTok signature timestamp', { status: 401 }) } if (Math.abs(nowSeconds - timestampSeconds) > TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS) { - logger.warn(`[${requestId}] TikTok webhook signature timestamp outside allowed skew`, { + logger.warn('TikTok webhook signature timestamp outside allowed skew', { + requestId, skewSeconds: TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS, timestampSeconds, nowSeconds, @@ -87,7 +88,7 @@ export function verifyTikTokSignature( const signedPayload = `${parts.timestamp}.${rawBody}` const computed = hmacSha256Hex(signedPayload, clientSecret) if (!safeCompare(computed, parts.signature)) { - logger.warn(`[${requestId}] TikTok signature verification failed`) + logger.warn('TikTok signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid TikTok signature', { status: 401 }) } @@ -139,9 +140,9 @@ export const tiktokHandler: WebhookProviderHandler = { const { isTikTokEventMatch } = await import('@/triggers/tiktok/utils') const event = stringField(toRecord(body), 'event') if (!isTikTokEventMatch(triggerId, event)) { - logger.debug( - `[${requestId}] TikTok event mismatch for trigger ${triggerId}. Event: ${event}. Skipping.` - ) + logger.debug(`TikTok event mismatch for trigger ${triggerId}. Event: ${event}. Skipping.`, { + requestId, + }) return false } return true diff --git a/apps/sim/lib/webhooks/providers/twilio-signature.ts b/apps/sim/lib/webhooks/providers/twilio-signature.ts index a8f74d25e41..547ed23ce9e 100644 --- a/apps/sim/lib/webhooks/providers/twilio-signature.ts +++ b/apps/sim/lib/webhooks/providers/twilio-signature.ts @@ -79,14 +79,15 @@ export async function verifyTwilioAuth( if (!authToken) { logger.warn( - `[${requestId}] ${providerLabel} webhook has no auth token configured — accepting request without signature verification. Configure an auth token to require signed requests.` + `${providerLabel} webhook has no auth token configured — accepting request without signature verification. Configure an auth token to require signed requests.`, + { requestId } ) return null } const signature = request.headers.get('x-twilio-signature') if (!signature) { - logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`) + logger.warn(`${providerLabel} webhook missing signature header`, { requestId }) return new NextResponse('Unauthorized - Missing Twilio signature', { status: 401 }) } @@ -97,10 +98,9 @@ export async function verifyTwilioAuth( params = Object.fromEntries(urlParams.entries()) } } catch (error) { - logger.error( - `[${requestId}] Error parsing ${providerLabel} webhook body for signature validation:`, - error - ) + logger.error(`Error parsing ${providerLabel} webhook body for signature validation:`, error, { + requestId, + }) return new NextResponse('Bad Request - Invalid body format', { status: 400 }) } @@ -108,7 +108,8 @@ export async function verifyTwilioAuth( const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) if (!isValidSignature) { - logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, { + logger.warn(`${providerLabel} signature verification failed`, { + requestId, url: fullUrl, signatureLength: signature.length, paramsCount: Object.keys(params).length, diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 492e3c83981..3db80586d01 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -74,14 +74,18 @@ export const typeformHandler: WebhookProviderHandler = { const secret = config.secret as string | undefined if (!formId) { - logger.warn(`[${ctx.requestId}] Missing formId for Typeform webhook ${ctx.webhook.id}`) + logger.warn(`Missing formId for Typeform webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) throw new WebhookDeploymentConfigurationError( 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' ) } if (!apiKey) { - logger.warn(`[${ctx.requestId}] Missing apiKey for Typeform webhook ${ctx.webhook.id}`) + logger.warn(`Missing apiKey for Typeform webhook ${ctx.webhook.id}`, { + requestId: ctx.requestId, + }) throw new WebhookDeploymentConfigurationError( 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' ) @@ -122,7 +126,8 @@ export const typeformHandler: WebhookProviderHandler = { (responseBody as Record).message || 'Unknown error' - logger.error(`[${ctx.requestId}] Typeform API error: ${errorMessage}`, { + logger.error(`Typeform API error: ${errorMessage}`, { + requestId: ctx.requestId, status: typeformResponse.status, response: responseBody, }) @@ -148,8 +153,8 @@ export const typeformHandler: WebhookProviderHandler = { const responseBody = await typeformResponse.json() logger.info( - `[${ctx.requestId}] Successfully created Typeform webhook for webhook ${ctx.webhook.id} with tag ${tag}`, - { webhookId: (responseBody as Record).id } + `Successfully created Typeform webhook for webhook ${ctx.webhook.id} with tag ${tag}`, + { requestId: ctx.requestId, webhookId: (responseBody as Record).id } ) if (!webhookTag && tag) { @@ -166,10 +171,9 @@ export const typeformHandler: WebhookProviderHandler = { throw error } - logger.error( - `[${ctx.requestId}] Error creating Typeform webhook for webhook ${ctx.webhook.id}`, - error - ) + logger.error(`Error creating Typeform webhook for webhook ${ctx.webhook.id}`, error, { + requestId: ctx.requestId, + }) throw new Error( getErrorMessage(error, 'Failed to create Typeform webhook. Please try again.') ) @@ -185,7 +189,8 @@ export const typeformHandler: WebhookProviderHandler = { if (!formId || !apiKey) { logger.warn( - `[${ctx.requestId}] Missing formId or apiKey for Typeform webhook deletion ${ctx.webhook.id}, skipping cleanup` + `Missing formId or apiKey for Typeform webhook deletion ${ctx.webhook.id}, skipping cleanup`, + { requestId: ctx.requestId } ) if (ctx.strict) throw new Error('Missing Typeform webhook deletion credentials') return @@ -202,17 +207,21 @@ export const typeformHandler: WebhookProviderHandler = { }) if (!typeformResponse.ok && typeformResponse.status !== 404) { - logger.warn( - `[${ctx.requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` - ) + logger.warn(`Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}`, { + requestId: ctx.requestId, + }) if (ctx.strict) { throw new Error(`Failed to delete Typeform webhook: ${typeformResponse.status}`) } } else { - logger.info(`[${ctx.requestId}] Successfully deleted Typeform webhook with tag ${tag}`) + logger.info(`Successfully deleted Typeform webhook with tag ${tag}`, { + requestId: ctx.requestId, + }) } } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting Typeform webhook (non-fatal)`, error) + logger.warn('Error deleting Typeform webhook (non-fatal)', error, { + requestId: ctx.requestId, + }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts index f6487fefa3f..a9338989006 100644 --- a/apps/sim/lib/webhooks/providers/utils.ts +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -82,12 +82,13 @@ export function verifyStandardWebhookRequest({ if (verification.valid) return null if (verification.reason === 'missing_headers') { - logger.warn(`[${requestId}] ${providerLabel} webhook missing signature headers`) + logger.warn(`${providerLabel} webhook missing signature headers`, { requestId }) return new NextResponse(`Unauthorized - Missing ${providerLabel} signature headers`, { status: 401, }) } - logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, { + logger.warn(`${providerLabel} signature verification failed`, { + requestId, reason: verification.reason, }) return new NextResponse(`Unauthorized - Invalid ${providerLabel} signature`, { status: 401 }) @@ -113,7 +114,7 @@ export function createHmacVerifier({ const secret = providerConfig[configKey] as string | undefined if (!secret) { if (requireSecret) { - logger.warn(`[${requestId}] ${providerLabel} webhook secret not configured`) + logger.warn(`${providerLabel} webhook secret not configured`, { requestId }) return new NextResponse(`Unauthorized - Missing ${providerLabel} webhook secret`, { status: 401, }) @@ -123,13 +124,14 @@ export function createHmacVerifier({ const signature = request.headers.get(headerName) if (!signature) { - logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`) + logger.warn(`${providerLabel} webhook missing signature header`, { requestId }) return new NextResponse(`Unauthorized - Missing ${providerLabel} signature`, { status: 401 }) } const isValid = await validateFn(secret, signature, rawBody) if (!isValid) { - logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, { + logger.warn(`${providerLabel} signature verification failed`, { + requestId, signatureLength: signature.length, secretLength: secret.length, }) @@ -191,7 +193,8 @@ export function skipByEventTypes( const eventType = (body as Record)?.type as string | undefined if (eventType && !eventTypes.includes(eventType)) { eventLogger.info( - `[${requestId}] ${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping` + `${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping`, + { requestId } ) return true } diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 2bafc09f8e3..96961a17bea 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -27,7 +27,7 @@ export const vercelHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { const secret = (providerConfig.webhookSecret as string | undefined)?.trim() if (!secret) { - logger.warn(`[${requestId}] Vercel webhook secret missing; rejecting delivery`) + logger.warn('Vercel webhook secret missing; rejecting delivery', { requestId }) return new NextResponse( 'Unauthorized - Vercel webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.', { status: 401 } @@ -36,12 +36,12 @@ export const vercelHandler: WebhookProviderHandler = { const signature = request.headers.get('x-vercel-signature') if (!signature) { - logger.warn(`[${requestId}] Vercel webhook missing x-vercel-signature header`) + logger.warn('Vercel webhook missing x-vercel-signature header', { requestId }) return new NextResponse('Unauthorized - Missing Vercel signature', { status: 401 }) } if (!verifyVercelSignature(secret, signature, rawBody)) { - logger.warn(`[${requestId}] Vercel signature verification failed`) + logger.warn('Vercel signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Vercel signature', { status: 401 }) } @@ -56,7 +56,8 @@ export const vercelHandler: WebhookProviderHandler = { if (triggerId && triggerId !== 'vercel_webhook') { const { isVercelEventMatch } = await import('@/triggers/vercel/utils') if (!isVercelEventMatch(triggerId, eventType)) { - logger.debug(`[${requestId}] Vercel event mismatch for trigger ${triggerId}. Skipping.`, { + logger.debug(`Vercel event mismatch for trigger ${triggerId}. Skipping.`, { + requestId, webhookId: webhook.id, workflowId: workflow.id, triggerId, @@ -112,7 +113,8 @@ export const vercelHandler: WebhookProviderHandler = { : undefined const notificationUrl = getNotificationUrl(webhook) - logger.info(`[${requestId}] Creating Vercel webhook`, { + logger.info('Creating Vercel webhook', { + requestId, triggerId, events, hasTeamId: !!teamId, @@ -181,10 +183,10 @@ export const vercelHandler: WebhookProviderHandler = { throw new Error('Vercel webhook creation succeeded but no webhook ID was returned') } - logger.info( - `[${requestId}] Successfully created webhook in Vercel for webhook ${webhook.id}.`, - { vercelWebhookId: externalId } - ) + logger.info(`Successfully created webhook in Vercel for webhook ${webhook.id}.`, { + requestId, + vercelWebhookId: externalId, + }) const signingSecret = responseBody.secret as string | undefined if (!signingSecret) { @@ -202,10 +204,11 @@ export const vercelHandler: WebhookProviderHandler = { } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Vercel webhook creation for webhook ${webhook.id}.`, - { message: err.message, stack: err.stack } - ) + logger.error(`Exception during Vercel webhook creation for webhook ${webhook.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -220,7 +223,8 @@ export const vercelHandler: WebhookProviderHandler = { if (!apiKey || !externalId) { logger.warn( - `[${requestId}] Missing apiKey or externalId for Vercel webhook deletion ${webhook.id}, skipping cleanup` + `Missing apiKey or externalId for Vercel webhook deletion ${webhook.id}, skipping cleanup`, + { requestId } ) if (ctx.strict) throw new Error('Missing Vercel webhook deletion credentials') return @@ -238,16 +242,16 @@ export const vercelHandler: WebhookProviderHandler = { }) if (!response.ok && response.status !== 404) { - logger.warn( - `[${requestId}] Failed to delete Vercel webhook (non-fatal): ${response.status}` - ) + logger.warn(`Failed to delete Vercel webhook (non-fatal): ${response.status}`, { + requestId, + }) if (ctx.strict) throw new Error(`Failed to delete Vercel webhook: ${response.status}`) } else { await response.body?.cancel() - logger.info(`[${requestId}] Successfully deleted Vercel webhook ${externalId}`) + logger.info(`Successfully deleted Vercel webhook ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Vercel webhook (non-fatal)`, error) + logger.warn('Error deleting Vercel webhook (non-fatal)', error, { requestId }) if (ctx.strict) throw error } }, diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 2a806ce70d0..693afd5cbc5 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -36,7 +36,8 @@ export const webflowHandler: WebhookProviderHandler = { } if (!siteId) { - logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { + logger.warn('Missing siteId for Webflow webhook creation.', { + requestId, webhookId: webhookRecord.id, }) throw new WebhookDeploymentConfigurationError( @@ -50,7 +51,8 @@ export const webflowHandler: WebhookProviderHandler = { } if (!triggerId) { - logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { + logger.warn('Missing triggerId for Webflow webhook creation.', { + requestId, webhookId: webhookRecord.id, }) throw new WebhookDeploymentConfigurationError( @@ -63,7 +65,8 @@ export const webflowHandler: WebhookProviderHandler = { : await getOAuthToken(userId, 'webflow') if (!accessToken) { logger.warn( - `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` + `Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.`, + { requestId } ) throw new Error( 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' @@ -81,7 +84,8 @@ export const webflowHandler: WebhookProviderHandler = { const webflowTriggerType = triggerTypeMap[triggerId] if (!webflowTriggerType) { - logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { + logger.warn(`Invalid triggerId for Webflow: ${triggerId}`, { + requestId, webhookId: webhookRecord.id, }) throw new Error(`Invalid Webflow trigger type: ${triggerId}`) @@ -116,30 +120,26 @@ export const webflowHandler: WebhookProviderHandler = { const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error' logger.error( - `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookRecord.id}. Status: ${webflowResponse.status}`, - { message: errorMessage, response: responseBody } + `Failed to create webhook in Webflow for webhook ${webhookRecord.id}. Status: ${webflowResponse.status}`, + { requestId, message: errorMessage, response: responseBody } ) throw new Error(errorMessage) } - logger.info( - `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`, - { - webflowWebhookId: responseBody.id || responseBody._id, - } - ) + logger.info(`Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`, { + requestId, + webflowWebhookId: responseBody.id || responseBody._id, + }) return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } } catch (error: unknown) { if (error instanceof WebhookDeploymentConfigurationError) throw error const err = error as Error - logger.error( - `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`, - { - message: err.message, - stack: err.stack, - } - ) + logger.error(`Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`, { + requestId, + message: err.message, + stack: err.stack, + }) throw error } }, @@ -156,7 +156,8 @@ export const webflowHandler: WebhookProviderHandler = { if (!siteId) { logger.warn( - `[${requestId}] Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + `Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup`, + { requestId } ) if (strict) throw new Error('Missing Webflow siteId for webhook deletion') return @@ -164,7 +165,8 @@ export const webflowHandler: WebhookProviderHandler = { if (!externalId) { logger.warn( - `[${requestId}] Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + `Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup`, + { requestId } ) if (strict) throw new Error('Missing Webflow externalId for webhook deletion') return @@ -172,7 +174,8 @@ export const webflowHandler: WebhookProviderHandler = { const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) if (!siteIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { + logger.warn('Invalid Webflow site ID format, skipping deletion', { + requestId, webhookId: webhookRecord.id, siteId: siteId.substring(0, 30), }) @@ -182,7 +185,8 @@ export const webflowHandler: WebhookProviderHandler = { const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) if (!webhookIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { + logger.warn('Invalid Webflow webhook ID format, skipping deletion', { + requestId, webhookId: webhookRecord.id, externalId: externalId.substring(0, 30), }) @@ -192,17 +196,17 @@ export const webflowHandler: WebhookProviderHandler = { const credentialId = config.credentialId as string | undefined if (!credentialId) { - logger.warn( - `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhookRecord.id}` - ) + logger.warn(`Missing credentialId for Webflow webhook deletion ${webhookRecord.id}`, { + requestId, + }) if (strict) throw new Error('Missing Webflow credentialId for webhook deletion') return } const accessToken = await getCredentialAccessToken(credentialId, requestId) if (!accessToken) { - const message = `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.` - logger.warn(message, { webhookId: webhookRecord.id }) + const message = 'Could not retrieve Webflow access token. Cannot delete webhook.' + logger.warn(message, { requestId, webhookId: webhookRecord.id }) if (strict) throw new Error(message) return } @@ -219,18 +223,18 @@ export const webflowHandler: WebhookProviderHandler = { if (!webflowResponse.ok && webflowResponse.status !== 404) { const responseBody = await webflowResponse.json().catch(() => ({})) - logger.warn( - `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, - { response: responseBody } - ) + logger.warn(`Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, { + requestId, + response: responseBody, + }) if (strict) { throw new Error(`Failed to delete Webflow webhook: ${webflowResponse.status}`) } } else { - logger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) + logger.info(`Successfully deleted Webflow webhook ${externalId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) + logger.warn('Error deleting Webflow webhook (non-fatal)', error, { requestId }) if (strict) throw error } }, @@ -296,7 +300,8 @@ export const webflowHandler: WebhookProviderHandler = { if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { logger.info( - `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping` + `Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping`, + { requestId } ) return true } diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 9748fdbcf0c..80b170de81b 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -173,10 +173,10 @@ async function handleWhatsAppVerification( challenge: string | null ): Promise { if (mode && token && challenge) { - logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) + logger.info(`WhatsApp verification request received for path: ${path}`, { requestId }) if (mode !== 'subscribe') { - logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) + logger.warn(`Invalid WhatsApp verification mode: ${mode}`, { requestId }) return new NextResponse('Invalid mode', { status: 400 }) } @@ -216,7 +216,7 @@ async function handleWhatsAppVerification( candidates++ if (safeCompare(token, verificationToken as string)) { - logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) + logger.info(`WhatsApp verification successful for webhook ${wh.id}`, { requestId }) return new NextResponse(challenge, { status: 200, headers: { @@ -235,7 +235,7 @@ async function handleWhatsAppVerification( return null } - logger.warn(`[${requestId}] No matching WhatsApp verification token found`) + logger.warn('No matching WhatsApp verification token found', { requestId }) return new NextResponse('Verification failed', { status: 403 }) } @@ -252,20 +252,20 @@ export const whatsappHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }) { const appSecret = providerConfig.appSecret as string | undefined if (!appSecret) { - logger.warn( - `[${requestId}] WhatsApp webhook missing appSecret in providerConfig — rejecting request` - ) + logger.warn('WhatsApp webhook missing appSecret in providerConfig — rejecting request', { + requestId, + }) return new NextResponse('Unauthorized - WhatsApp app secret not configured', { status: 401 }) } const signature = request.headers.get('x-hub-signature-256') if (!signature) { - logger.warn(`[${requestId}] WhatsApp webhook missing signature header`) + logger.warn('WhatsApp webhook missing signature header', { requestId }) return new NextResponse('Unauthorized - Missing WhatsApp signature', { status: 401 }) } if (!validateWhatsAppSignature(appSecret, signature, rawBody)) { - logger.warn(`[${requestId}] WhatsApp signature verification failed`) + logger.warn('WhatsApp signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid WhatsApp signature', { status: 401 }) } @@ -427,9 +427,9 @@ export const whatsappHandler: WebhookProviderHandler = { }, handleEmptyInput(requestId: string) { - logger.info( - `[${requestId}] No messages or status updates in WhatsApp payload, skipping execution` - ) + logger.info('No messages or status updates in WhatsApp payload, skipping execution', { + requestId, + }) return { message: 'No messages or status updates in WhatsApp payload' } }, } diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts index c231c654578..aecba884097 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -89,26 +89,24 @@ export const zendeskHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - logger.warn(`[${requestId}] Zendesk webhook secret not configured`) + logger.warn('Zendesk webhook secret not configured', { requestId }) return new NextResponse('Unauthorized - Missing Zendesk webhook secret', { status: 401 }) } const signature = request.headers.get('X-Zendesk-Webhook-Signature') const timestamp = request.headers.get('X-Zendesk-Webhook-Signature-Timestamp') if (!signature || !timestamp) { - logger.warn(`[${requestId}] Zendesk webhook missing signature headers`) + logger.warn('Zendesk webhook missing signature headers', { requestId }) return new NextResponse('Unauthorized - Missing Zendesk signature', { status: 401 }) } if (!isZendeskTimestampFresh(timestamp)) { - logger.warn(`[${requestId}] Zendesk webhook timestamp outside the allowed window`, { - timestamp, - }) + logger.warn('Zendesk webhook timestamp outside the allowed window', { requestId, timestamp }) return new NextResponse('Unauthorized - Stale Zendesk timestamp', { status: 401 }) } if (!validateZendeskSignature(secret, signature, timestamp, rawBody)) { - logger.warn(`[${requestId}] Zendesk signature verification failed`) + logger.warn('Zendesk signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Zendesk signature', { status: 401 }) } @@ -123,9 +121,9 @@ export const zendeskHandler: WebhookProviderHandler = { const { isZendeskEventMatch } = await import('@/triggers/zendesk/utils') if (!isZendeskEventMatch(triggerId, eventType || '')) { - logger.debug( - `[${requestId}] Zendesk event '${eventType}' does not match trigger ${triggerId}, skipping` - ) + logger.debug(`Zendesk event '${eventType}' does not match trigger ${triggerId}, skipping`, { + requestId, + }) return false } return true @@ -217,7 +215,8 @@ export const zendeskHandler: WebhookProviderHandler = { if (!createRes.ok) { const detail = await createRes.text().catch(() => '') - logger.error(`[${ctx.requestId}] Failed to create Zendesk webhook (${createRes.status})`, { + logger.error(`Failed to create Zendesk webhook (${createRes.status})`, { + requestId: ctx.requestId, detail, }) if (createRes.status === 401 || createRes.status === 403) { @@ -238,8 +237,8 @@ export const zendeskHandler: WebhookProviderHandler = { if (!secretRes.ok) { const detail = await secretRes.text().catch(() => '') logger.error( - `[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`, - { detail } + `Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`, + { requestId: ctx.requestId, detail } ) await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`) @@ -252,7 +251,7 @@ export const zendeskHandler: WebhookProviderHandler = { throw new Error('Zendesk did not return a signing secret for the webhook.') } - logger.info(`[${ctx.requestId}] Created Zendesk webhook ${externalId}`) + logger.info(`Created Zendesk webhook ${externalId}`, { requestId: ctx.requestId }) return { providerConfigUpdates: { externalId, webhookSecret: secret } } }, @@ -265,15 +264,17 @@ export const zendeskHandler: WebhookProviderHandler = { if (!subdomain || !email || !apiToken || !externalId) { if (ctx.strict) throw new Error('Missing Zendesk credentials or webhook ID for deletion.') - logger.warn( - `[${ctx.requestId}] Skipping Zendesk webhook cleanup — missing credentials or webhook ID` - ) + logger.warn('Skipping Zendesk webhook cleanup — missing credentials or webhook ID', { + requestId: ctx.requestId, + }) return } if (!isValidZendeskSubdomain(subdomain)) { if (ctx.strict) throw new Error('Invalid Zendesk subdomain for deletion.') - logger.warn(`[${ctx.requestId}] Skipping Zendesk webhook cleanup — invalid subdomain`) + logger.warn('Skipping Zendesk webhook cleanup — invalid subdomain', { + requestId: ctx.requestId, + }) return } @@ -284,11 +285,11 @@ export const zendeskHandler: WebhookProviderHandler = { if (!res.ok && res.status !== 404) { if (ctx.strict) throw new Error(`Failed to delete Zendesk webhook: ${res.status}`) - logger.warn( - `[${ctx.requestId}] Failed to delete Zendesk webhook ${externalId} (non-fatal): ${res.status}` - ) + logger.warn(`Failed to delete Zendesk webhook ${externalId} (non-fatal): ${res.status}`, { + requestId: ctx.requestId, + }) return } - logger.info(`[${ctx.requestId}] Deleted Zendesk webhook ${externalId}`) + logger.info(`Deleted Zendesk webhook ${externalId}`, { requestId: ctx.requestId }) }, } diff --git a/apps/sim/lib/webhooks/providers/zoho-desk.ts b/apps/sim/lib/webhooks/providers/zoho-desk.ts index 4ca373f9eed..3c94d389c90 100644 --- a/apps/sim/lib/webhooks/providers/zoho-desk.ts +++ b/apps/sim/lib/webhooks/providers/zoho-desk.ts @@ -295,7 +295,8 @@ export const zohoDeskHandler: WebhookProviderHandler = { const bodyText = await response.text() if (!response.ok) { - logger.error(`[${requestId}] Failed to create Zoho Desk webhook`, { + logger.error('Failed to create Zoho Desk webhook', { + requestId, status: response.status, body: truncate(bodyText, 500), }) @@ -321,7 +322,7 @@ export const zohoDeskHandler: WebhookProviderHandler = { ) } - logger.info(`[${requestId}] Created Zoho Desk webhook`, { externalId }) + logger.info('Created Zoho Desk webhook', { requestId, externalId }) return { // externalId (the JWT `aud` claim) and apiDomain are provider-managed and // are SYSTEM_MANAGED_FIELDS, so they are excluded from the redeploy config @@ -373,7 +374,7 @@ export const zohoDeskHandler: WebhookProviderHandler = { }) if (!response.ok && response.status !== 404) { - logger.warn(`[${requestId}] Failed to delete Zoho Desk webhook`, { status: response.status }) + logger.warn('Failed to delete Zoho Desk webhook', { requestId, status: response.status }) if (strict) throw new Error(`Zoho Desk webhook delete failed: ${response.status}`) } }, @@ -385,7 +386,7 @@ export const zohoDeskHandler: WebhookProviderHandler = { }: AuthContext): Promise { const token = request.headers.get('x-zdesk-jwt') if (!token) { - logger.warn(`[${requestId}] Zoho Desk webhook missing X-ZDesk-JWT header`) + logger.warn('Zoho Desk webhook missing X-ZDesk-JWT header', { requestId }) return new NextResponse('Unauthorized - Missing Zoho Desk JWT', { status: 401 }) } @@ -402,7 +403,7 @@ export const zohoDeskHandler: WebhookProviderHandler = { // subscription, so accepting any org-JWKS RS256 token matching only the // issuer would be a verification bypass. Reject instead of skipping a claim. if (!orgId || !webhookId) { - logger.warn(`[${requestId}] Zoho Desk webhook missing orgId/webhookId; rejecting`) + logger.warn('Zoho Desk webhook missing orgId/webhookId; rejecting', { requestId }) return new NextResponse('Unauthorized - webhook not fully provisioned', { status: 401 }) } @@ -438,7 +439,8 @@ export const zohoDeskHandler: WebhookProviderHandler = { }) return null } catch (error) { - logger.warn(`[${requestId}] Zoho Desk JWT verification failed`, { + logger.warn('Zoho Desk JWT verification failed', { + requestId, message: toError(error).message, }) return new NextResponse('Unauthorized - Invalid Zoho Desk JWT', { status: 401 }) @@ -455,7 +457,8 @@ export const zohoDeskHandler: WebhookProviderHandler = { // than silently drop if that ever changes, so batched deliveries are visible. if (body.length > 1) { logger.warn( - `[${requestId}] Zoho Desk delivered ${body.length} events in one payload; processing the first only` + `Zoho Desk delivered ${body.length} events in one payload; processing the first only`, + { requestId } ) } const event = body[0] diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 85d2fe8cd40..fb50705c855 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -87,7 +87,8 @@ async function resolveZoomChallengeSecrets( const secretToken = typeof config.secretToken === 'string' ? config.secretToken : '' return { secretToken } } catch (error) { - logger.warn(`[${requestId}] Failed to resolve Zoom webhook secret for challenge`, { + logger.warn('Failed to resolve Zoom webhook secret for challenge', { + requestId, error: toError(error).message, path, }) @@ -111,9 +112,9 @@ export const zoomHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secretToken = providerConfig.secretToken as string | undefined if (!secretToken) { - logger.warn( - `[${requestId}] Zoom webhook missing secretToken in providerConfig — rejecting request` - ) + logger.warn('Zoom webhook missing secretToken in providerConfig — rejecting request', { + requestId, + }) return new NextResponse('Unauthorized - Zoom secret token not configured', { status: 401 }) } @@ -121,12 +122,12 @@ export const zoomHandler: WebhookProviderHandler = { const timestamp = request.headers.get('x-zm-request-timestamp') if (!signature || !timestamp) { - logger.warn(`[${requestId}] Zoom webhook missing signature or timestamp header`) + logger.warn('Zoom webhook missing signature or timestamp header', { requestId }) return new NextResponse('Unauthorized - Missing Zoom signature', { status: 401 }) } if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { - logger.warn(`[${requestId}] Zoom webhook signature verification failed`) + logger.warn('Zoom webhook signature verification failed', { requestId }) return new NextResponse('Unauthorized - Invalid Zoom signature', { status: 401 }) } @@ -146,13 +147,8 @@ export const zoomHandler: WebhookProviderHandler = { const { isZoomEventMatch } = await import('@/triggers/zoom/utils') if (!isZoomEventMatch(triggerId, event)) { logger.debug( - `[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`, - { - webhookId: wh.id, - workflowId: workflow.id, - triggerId, - receivedEvent: event, - } + `Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`, + { requestId, webhookId: wh.id, workflowId: workflow.id, triggerId, receivedEvent: event } ) return false } @@ -184,12 +180,12 @@ export const zoomHandler: WebhookProviderHandler = { return null } - logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`) + logger.info(`Zoom URL validation request received for path: ${path}`, { requestId }) const signature = request.headers.get('x-zm-signature') const timestamp = request.headers.get('x-zm-request-timestamp') if (!signature || !timestamp) { - logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`) + logger.warn('Zoom challenge request missing signature headers — rejecting', { requestId }) return null } @@ -200,7 +196,7 @@ export const zoomHandler: WebhookProviderHandler = { try { rows = await resolveZoomChallengeSecrets(path, requestId) } catch (err) { - logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err) + logger.warn('Failed to look up webhook secret for Zoom validation', err, { requestId }) return null } @@ -220,7 +216,8 @@ export const zoomHandler: WebhookProviderHandler = { } logger.warn( - `[${requestId}] Zoom challenge: no matching secret for path ${path} (${rows.length} webhook row(s))` + `Zoom challenge: no matching secret for path ${path} (${rows.length} webhook row(s))`, + { requestId } ) return null }, diff --git a/apps/sim/lib/webhooks/slack-custom-ingress.ts b/apps/sim/lib/webhooks/slack-custom-ingress.ts index 31105ca45a7..84dab841f46 100644 --- a/apps/sim/lib/webhooks/slack-custom-ingress.ts +++ b/apps/sim/lib/webhooks/slack-custom-ingress.ts @@ -101,11 +101,11 @@ export async function authenticateSlackCustomBotRequest({ }: SlackCustomBotRequestOptions): Promise { const botCredential = await getSlackBotCredential(credentialId) if (!botCredential) { - logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`) + logger.warn(`Unknown Slack bot credential ${credentialId}`, { requestId }) return new NextResponse(null, { status: 404 }) } if (!botCredential.signingSecret) { - logger.warn(`[${requestId}] Slack bot credential ${credentialId} has no signing secret`) + logger.warn(`Slack bot credential ${credentialId} has no signing secret`, { requestId }) return new NextResponse(null, { status: 404 }) } @@ -128,9 +128,9 @@ export async function dispatchSlackCustomBotCredential({ }: DispatchSlackCustomBotOptions): Promise { const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack') if (webhooks.length === 0) { - logger.info( - `[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run` - ) + logger.info(`No active trigger for bot credential ${credentialId}; nothing to run`, { + requestId, + }) return [] } diff --git a/apps/sim/lib/webhooks/slack-dispatch.ts b/apps/sim/lib/webhooks/slack-dispatch.ts index 21adaa17453..a92f87f6399 100644 --- a/apps/sim/lib/webhooks/slack-dispatch.ts +++ b/apps/sim/lib/webhooks/slack-dispatch.ts @@ -128,19 +128,17 @@ export async function dispatchSlackWebhooks( if (result.outcome === 'ignored' && result.reason === 'filtered') { const rawEvent = payload.event as Record | undefined const providerConfig = toRecord(foundWebhook.providerConfig) - logger.info( - `[${requestId}] Event skipped by trigger filter for webhook ${foundWebhook.id}`, - { - eventKey: resolveSlackEventKey(payload), - configuredEvent: providerConfig.eventType, - channelType: rawEvent?.channel_type, - subtype: rawEvent?.subtype, - isThreadReply: - typeof rawEvent?.thread_ts === 'string' && rawEvent.thread_ts !== rawEvent.ts, - threadsSetting: providerConfig.threads, - botId: rawEvent?.bot_id, - } - ) + logger.debug(`Event skipped by trigger filter for webhook ${foundWebhook.id}`, { + requestId, + eventKey: resolveSlackEventKey(payload), + configuredEvent: providerConfig.eventType, + channelType: rawEvent?.channel_type, + subtype: rawEvent?.subtype, + isThreadReply: + typeof rawEvent?.thread_ts === 'string' && rawEvent.thread_ts !== rawEvent.ts, + threadsSetting: providerConfig.threads, + botId: rawEvent?.bot_id, + }) } return result diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 23d8f5cf899..7c3961404a0 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -107,7 +107,7 @@ async function writeCustomTools( }) .where(eq(customTools.id, tool.id)) - logger.info(`[${requestId}] Updated legacy tool ${tool.id}`) + logger.info(`Updated legacy tool ${tool.id}`, { requestId }) continue } } @@ -131,7 +131,7 @@ async function writeCustomTools( .onConflictDoNothing({ target: [customTools.workspaceId, customTools.title] }) .returning({ id: customTools.id }) if (inserted.length === 0) { - logger.info(`[${requestId}] Skipped custom tool "${tool.title}": title already exists`) + logger.info(`Skipped custom tool "${tool.title}": title already exists`, { requestId }) skippedTitles.push(tool.title) } continue diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index 973640be7f5..a492e878fb4 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -871,7 +871,8 @@ const syncActiveSideEffects = async (rawPayload: unknown): Promise => { .limit(1) if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow missing during deployment side-effect sync`, { + logger.warn('Workflow missing during deployment side-effect sync', { + requestId, workflowId: payload.workflowId, }) return @@ -893,7 +894,8 @@ const syncActiveSideEffects = async (rawPayload: unknown): Promise => { .limit(1) if (!versionRow?.isActive) { - logger.info(`[${requestId}] Skipping stale deployment side-effect sync`, { + logger.info('Skipping stale deployment side-effect sync', { + requestId, workflowId: payload.workflowId, deploymentVersionId: payload.deploymentVersionId, }) @@ -1073,13 +1075,14 @@ async function syncInactiveDeploymentCleanup(params: { requestId: params.requestId, }) if (complete) return - logger.info( - `[${params.requestId}] Inline inactive-deployment cleanup has more rows; continuing through the outbox` - ) + logger.info('Inline inactive-deployment cleanup has more rows; continuing through the outbox', { + requestId: params.requestId, + }) } catch (cleanupError) { logger.warn( - `[${params.requestId}] Inline inactive-deployment cleanup failed; deferring to outbox retry`, - cleanupError + 'Inline inactive-deployment cleanup failed; deferring to outbox retry', + cleanupError, + { requestId: params.requestId } ) } await enqueueWorkflowInactiveDeploymentCleanup(db, { @@ -1235,7 +1238,8 @@ async function cleanupStaleDeploymentIfNeeded(params: { return true } - logger.info(`[${params.requestId}] Cleaning up stale deployment side effects`, { + logger.info('Cleaning up stale deployment side effects', { + requestId: params.requestId, workflowId: params.payload.workflowId, deploymentVersionId: params.payload.deploymentVersionId, }) diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 663b304d251..052cd0d7deb 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -294,7 +294,7 @@ export async function executeWorkflow( attachExecutionResult(error, executionResult) } const errorDiagnostic = loggingSession.projectDiagnosticError(error) - logger.error(`[${requestId}] Workflow execution failed`, errorDiagnostic) + logger.error('Workflow execution failed', errorDiagnostic, { requestId }) captureServerEvent( actorUserId, diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 13d7547d1eb..9bcad561ff9 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -285,7 +285,7 @@ async function clearExecutionCancellationSafely( try { await clearExecutionCancellation(executionId) } catch (error) { - logger.error(`[${requestId}] Failed to clear execution cancellation`, { error, executionId }) + logger.error('Failed to clear execution cancellation', { requestId, error, executionId }) } } @@ -326,7 +326,7 @@ async function recordSettledRun( try { await updateWorkflowRunCounts(workflowId) } catch (error) { - logger.error(`[${requestId}] Failed to update run counts`, { error }) + logger.error('Failed to update run counts', { requestId, error }) } } @@ -345,8 +345,9 @@ function buildTraceSpansForLog( return buildTraceSpans(result) } catch (error) { logger.error( - `[${requestId}] Failed to build trace spans; finalizing without them`, - loggingSession.projectDiagnosticError(error, { executionId }) + 'Failed to build trace spans; finalizing without them', + loggingSession.projectDiagnosticError(error, { executionId }), + { requestId } ) return { traceSpans: [], totalDuration: result.metadata?.duration ?? 0 } } @@ -418,11 +419,12 @@ async function finalizeExecutionOutcome(params: { } } catch (error) { logger.warn( - `[${requestId}] Post-execution finalization failed`, + 'Post-execution finalization failed', loggingSession.projectDiagnosticError(error, { executionId, status: result.status, - }) + }), + { requestId } ) } @@ -462,8 +464,9 @@ async function finalizeExecutionError(params: { } } catch (postExecError) { logger.error( - `[${requestId}] Post-execution error logging failed`, - loggingSession.projectDiagnosticError(postExecError, { executionId }) + 'Post-execution error logging failed', + loggingSession.projectDiagnosticError(postExecError, { executionId }), + { requestId } ) } @@ -730,7 +733,7 @@ async function executeWorkflowCoreImpl( resumeDeploymentVersionId, providedWorkspaceId ) - logger.info(`[${requestId}] Using admitted historical deployment state (resumed execution)`) + logger.info('Using admitted historical deployment state (resumed execution)', { requestId }) return { blocks: deployedData.blocks, edges: deployedData.edges, @@ -742,7 +745,8 @@ async function executeWorkflowCoreImpl( if (metadata.workflowStateOverride) { const override = metadata.workflowStateOverride - logger.info(`[${requestId}] Using workflow state override (diff workflow execution)`, { + logger.debug('Using workflow state override (diff workflow execution)', { + requestId, blocksCount: Object.keys(override.blocks).length, edgesCount: override.edges.length, }) @@ -762,9 +766,9 @@ async function executeWorkflowCoreImpl( throw new Error('Workflow not found or not yet saved') } - logger.info( - `[${requestId}] Using draft workflow state from normalized tables (client execution)` - ) + logger.info('Using draft workflow state from normalized tables (client execution)', { + requestId, + }) return { blocks: draftData.blocks, edges: draftData.edges, @@ -775,7 +779,7 @@ async function executeWorkflowCoreImpl( } const deployedData = await loadDeployedWorkflowState(workflowId, providedWorkspaceId) - logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`) + logger.debug('Using deployed workflow state (deployed execution)', { requestId }) return { blocks: deployedData.blocks, edges: deployedData.edges, @@ -887,7 +891,8 @@ async function executeWorkflowCoreImpl( (resumePendingQueue !== undefined || resumeRemainingEdges !== undefined || resumeTerminalNoop) ) { resolvedTriggerBlockId = undefined - logger.info(`[${requestId}] Skipping trigger resolution for resume execution`, { + logger.info('Skipping trigger resolution for resume execution', { + requestId, pendingQueueLength: resumePendingQueue?.length ?? 0, remainingEdgeCount: resumeRemainingEdges?.length ?? 0, resumeTerminalNoop, @@ -907,7 +912,8 @@ async function executeWorkflowCoreImpl( } resolvedTriggerBlockId = startBlock.blockId - logger.info(`[${requestId}] Identified trigger block for ${executionKind} execution:`, { + logger.debug(`Identified trigger block for ${executionKind} execution:`, { + requestId, blockId: resolvedTriggerBlockId, blockType: startBlock.block.type, path: startBlock.path, @@ -959,7 +965,8 @@ async function executeWorkflowCoreImpl( // Create and execute workflow with callbacks if (resumeFromSnapshot) { - logger.info(`[${requestId}] Resume execution detected`, { + logger.info('Resume execution detected', { + requestId, resumePendingQueue, hasState: !!snapshot.state, stateBlockStatesCount: snapshot.state @@ -984,12 +991,13 @@ async function executeWorkflowCoreImpl( persistenceSucceeded = true })().catch((error) => { logger.warn( - `[${requestId}] Block completion persistence failed`, + 'Block completion persistence failed', loggingSession.projectDiagnosticError(error, { executionId, blockId, blockType, - }) + }), + { requestId } ) }) @@ -1008,12 +1016,13 @@ async function executeWorkflowCoreImpl( ) } catch (error) { logger.warn( - `[${requestId}] Block completion callback failed`, + 'Block completion callback failed', loggingSession.projectDiagnosticError(error, { executionId, blockId, blockType, - }) + }), + { requestId } ) } })() @@ -1036,12 +1045,13 @@ async function executeWorkflowCoreImpl( persistenceSucceeded = true })().catch((error) => { logger.warn( - `[${requestId}] Block start persistence failed`, + 'Block start persistence failed', loggingSession.projectDiagnosticError(error, { executionId, blockId, blockType, - }) + }), + { requestId } ) }) @@ -1060,12 +1070,13 @@ async function executeWorkflowCoreImpl( ) } catch (error) { logger.warn( - `[${requestId}] Block start callback failed`, + 'Block start callback failed', loggingSession.projectDiagnosticError(error, { executionId, blockId, blockType, - }) + }), + { requestId } ) } })() @@ -1358,14 +1369,16 @@ async function executeWorkflowCoreImpl( }) } catch (postExecError) { logger.error( - `[${requestId}] Post-execution logging failed`, - loggingSession.projectDiagnosticError(postExecError, { executionId }) + 'Post-execution logging failed', + loggingSession.projectDiagnosticError(postExecError, { executionId }), + { requestId } ) } })() ) - logger.info(`[${requestId}] Workflow execution completed`, { + logger.info('Workflow execution completed', { + requestId, success: result.success, status: result.status, duration: result.metadata?.duration, @@ -1373,13 +1386,16 @@ async function executeWorkflowCoreImpl( return result } catch (error: unknown) { - logFailureOnce(logger, `[${requestId}] Execution failed:`, error, { + logFailureOnce(logger, 'Execution failed', error, { metadata: () => { const errorCause = describeErrorCause(error) - return projectResolvedSecretDiagnosticError(error, resolvedSecretTraceRegistry, { - workflowId, - ...(errorCause ? { cause: errorCause } : {}), - }) + return { + requestId, + ...projectResolvedSecretDiagnosticError(error, resolvedSecretTraceRegistry, { + workflowId, + ...(errorCause ? { cause: errorCause } : {}), + }), + } }, executionId, }) @@ -1416,8 +1432,9 @@ async function executeWorkflowCoreImpl( } } catch (postExecError) { logger.error( - `[${requestId}] Post-execution error logging failed`, - loggingSession.projectDiagnosticError(postExecError, { executionId }) + 'Post-execution error logging failed', + loggingSession.projectDiagnosticError(postExecError, { executionId }), + { requestId } ) } })() diff --git a/apps/sim/lib/workflows/executor/resume-execution.ts b/apps/sim/lib/workflows/executor/resume-execution.ts index ebaea1ad89c..2ba5d6e4d06 100644 --- a/apps/sim/lib/workflows/executor/resume-execution.ts +++ b/apps/sim/lib/workflows/executor/resume-execution.ts @@ -190,7 +190,8 @@ export async function executeResumeWorkflow({ }) } catch (error) { const message = toError(error).message - logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { + logger.error('Failed to validate paused execution snapshot', { + requestId, workflowId, executionId, error: message, @@ -201,7 +202,8 @@ export async function executeResumeWorkflow({ const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding const resumeExecutionId = generateId() - logger.info(`[${requestId}] Preprocessing resume execution`, { + logger.info('Preprocessing resume execution', { + requestId, workflowId, parentExecutionId: executionId, resumeExecutionId, @@ -227,7 +229,8 @@ export async function executeResumeWorkflow({ const statusCode = preprocessResult.error?.statusCode || 400 const message = preprocessResult.error?.message || 'Failed to validate resume execution. Please try again.' - logger.warn(`[${requestId}] Preprocessing failed for resume`, { + logger.warn('Preprocessing failed for resume', { + requestId, workflowId, parentExecutionId: executionId, error: message, @@ -236,7 +239,8 @@ export async function executeResumeWorkflow({ throw new ResumeWorkflowExecutionError(statusCode, message, statusCode < 500) } - logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { + logger.info('Preprocessing passed, proceeding with resume', { + requestId, workflowId, parentExecutionId: executionId, resumeExecutionId, diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index f5e2269e452..971186c58f6 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -45,10 +45,11 @@ async function notifyWorkflowArchived(workflowId: string, requestId: string): Pr }) if (!socketResponse.ok) { - logger.warn(`[${requestId}] Failed to notify Socket.IO about archived workflow ${workflowId}`) + logger.warn(`Failed to notify Socket.IO about archived workflow ${workflowId}`, { requestId }) } } catch (error) { - logger.warn(`[${requestId}] Error notifying Socket.IO about archived workflow ${workflowId}`, { + logger.warn(`Error notifying Socket.IO about archived workflow ${workflowId}`, { + requestId, error, }) } @@ -82,14 +83,15 @@ async function cleanupExternalWebhooksForWorkflow( } catch (error) { if (strict) throw error logger.warn( - `[${requestId}] Failed to cleanup external webhook ${webhookData.webhook.id} for workflow ${workflowId}`, - { error } + `Failed to cleanup external webhook ${webhookData.webhook.id} for workflow ${workflowId}`, + { requestId, error } ) } } } catch (error) { if (strict) throw error - logger.warn(`[${requestId}] Error during external webhook cleanup for workflow ${workflowId}`, { + logger.warn(`Error during external webhook cleanup for workflow ${workflowId}`, { + requestId, error, }) } @@ -224,7 +226,7 @@ export async function restoreWorkflow( ) }) - logger.info(`[${options.requestId}] Restored workflow ${workflowId}`) + logger.info(`Restored workflow ${workflowId}`, { requestId: options.requestId }) return { restored: true, @@ -305,7 +307,7 @@ export async function archiveWorkflowsByIdsInWorkspace( */ export async function disableUserResources(userId: string): Promise { const requestId = generateRequestId() - logger.info(`[${requestId}] Disabling resources for banned user ${userId}`) + logger.info(`Disabling resources for banned user ${userId}`, { requestId }) const { archiveWorkspace } = await import('@/lib/workspaces/lifecycle') @@ -338,7 +340,8 @@ export async function disableUserResources(userId: string): Promise { await signalExecutionStops(executions) } catch (error) { stopFailed = true - logger.error(`[${requestId}] Failed to stop runs for banned user ${userId}`, { + logger.error(`Failed to stop runs for banned user ${userId}`, { + requestId, workspaceIds, error: getErrorMessage(error), }) @@ -367,7 +370,8 @@ export async function disableUserResources(userId: string): Promise { await Promise.all(stopping) if (failures.length > 0) { - logger.error(`[${requestId}] Failed to archive workspaces for banned user ${userId}`, { + logger.error(`Failed to archive workspaces for banned user ${userId}`, { + requestId, failures: failures.map(({ workspaceId, error }) => ({ workspaceId, error: getErrorMessage(error), @@ -380,7 +384,8 @@ export async function disableUserResources(userId: string): Promise { } logger.info( - `[${requestId}] Disabled resources for user ${userId}: archived ${ownedWorkspaces.length} workspaces, deleted API keys, ${stopFailed ? 'failed to stop some of their runs' : 'stopped their runs'}` + `Disabled resources for user ${userId}: archived ${ownedWorkspaces.length} workspaces, deleted API keys, ${stopFailed ? 'failed to stop some of their runs' : 'stopped their runs'}`, + { requestId } ) } @@ -490,10 +495,10 @@ export async function finishWorkflowArchive( try { mcpPubSub.publishWorkflowToolsChanged({ serverId, workspaceId }) } catch (error) { - logger.warn( - `[${options.requestId}] MCP tools-changed publish failed for server ${serverId}`, - { error } - ) + logger.warn(`MCP tools-changed publish failed for server ${serverId}`, { + requestId: options.requestId, + error, + }) } } } diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 72454ca8bec..25aabf76f8a 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -300,7 +300,7 @@ async function executeImportWorkflowIntoWorkspace( */ const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState) if (warnings.length > 0) { - logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings }) + logger.warn('Normalized imported workflow with warnings', { requestId, warnings }) } const workflowState: WorkflowState = { ...parsedState, ...preparedState } @@ -392,7 +392,8 @@ async function executeImportWorkflowIntoWorkspace( } }) } catch (error) { - logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, { + logger.error('Failed to persist imported workflow, rolling back', { + requestId, workflowId, error: getErrorMessage(error, 'Unknown error'), }) @@ -406,8 +407,13 @@ async function executeImportWorkflowIntoWorkspace( await db.delete(workflow).where(eq(workflow.id, workflowId)) } catch (rollbackError) { logger.error( - `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, - { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') } + `Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, + { + requestId, + workflowId, + workspaceId, + error: getErrorMessage(rollbackError, 'Unknown error'), + } ) } return { success: false, status: 500, error: 'Failed to save workflow state' } @@ -426,19 +432,22 @@ async function executeImportWorkflowIntoWorkspace( userId ) if (saved > 0 || toolErrors.length > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, { + logger.info(`Persisted ${saved} custom tool(s) from import`, { + requestId, workflowId, errors: toolErrors, }) } } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools from import`, { + logger.error('Failed to persist custom tools from import', { + requestId, workflowId, error: getErrorMessage(error, 'Unknown error'), }) } - logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { + logger.info(`Imported workflow ${workflowId} into workspace ${workspaceId}`, { + requestId, name: created.workflow.name, blocksCount: Object.keys(workflowState.blocks).length, }) diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 5f9fe84f0ad..d430cb8c23f 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -173,7 +173,7 @@ export async function performFullDeploy( idempotencyKey, }) } catch (error) { - logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error }) + logger.error('Deployment preparation failed', { requestId, workflowId, error }) return { success: false, error: getErrorMessage(error, 'Failed to prepare workflow deployment'), @@ -398,7 +398,8 @@ async function processStableDeploymentPreparationNow( try { return await processWorkflowDeploymentOutboxEvent(outboxEventId) } catch (error) { - logger.warn(`[${requestId}] Inline deployment preparation errored; outbox will retry`, { + logger.warn('Inline deployment preparation errored; outbox will retry', { + requestId, outboxEventId, error, }) @@ -602,7 +603,7 @@ export async function performFullUndeploy( return { success: false, error: result.error || 'Failed to undeploy workflow' } } - logger.info(`[${requestId}] Workflow undeployed successfully: ${workflowId}`) + logger.info(`Workflow undeployed successfully: ${workflowId}`, { requestId }) try { const { PlatformEvents } = await import('@/lib/core/telemetry') @@ -799,11 +800,7 @@ export async function performActivateVersion( idempotencyKey, }) } catch (error) { - logger.error(`[${requestId}] Version activation preparation failed`, { - workflowId, - version, - error, - }) + logger.error('Version activation preparation failed', { requestId, workflowId, version, error }) return { success: false, error: getErrorMessage(error, 'Failed to prepare version activation'), @@ -918,23 +915,22 @@ async function processDeploymentSideEffectsNow( const result = await processWorkflowDeploymentOutboxEvent(outboxEventId) if (result === 'completed') return undefined if (result === 'dead_letter' || result === 'not_found') { - logger.error(`[${requestId}] Deployment side-effect sync cannot be retried automatically`, { + logger.error('Deployment side-effect sync cannot be retried automatically', { + requestId, outboxEventId, result, }) return 'Deployment saved, but trigger, schedule, and MCP sync could not be queued. Redeploy if triggers or schedules look stale.' } - logger.warn(`[${requestId}] Deployment side-effect sync queued for retry`, { + logger.warn('Deployment side-effect sync queued for retry', { + requestId, outboxEventId, result, }) return 'Deployment saved. Trigger, schedule, and MCP sync is queued and may finish shortly.' } catch (error) { - logger.warn(`[${requestId}] Deployment side-effect sync queued for retry`, { - outboxEventId, - error, - }) + logger.warn('Deployment side-effect sync queued for retry', { requestId, outboxEventId, error }) return 'Deployment saved. Trigger, schedule, and MCP sync is queued and may finish shortly.' } } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 6f4c519d729..f5da5ffc98c 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -268,7 +268,8 @@ export async function createWorkflowWithState({ return { success: true, workflow } } catch (error) { if (asOrchestrationError(error)) throw error - logger.error(`[${params.requestId ?? 'create'}] Failed to persist workflow state`, { + logger.error('Failed to persist workflow state', { + requestId: params.requestId, workspaceId: params.workspaceId, error, }) @@ -359,7 +360,7 @@ export async function performCreateWorkflowTransition( const conflictingConstraint = getPostgresErrorCode(error) === '23505' ? getPostgresConstraintName(error) : undefined if (conflictingConstraint === WORKFLOW_PRIMARY_KEY) { - logger.warn(`[${requestId}] Workflow id is already taken`, { workflowId }) + logger.warn('Workflow id is already taken', { requestId, workflowId }) return { success: false, error: 'A workflow with this ID already exists', @@ -378,14 +379,15 @@ export async function performCreateWorkflowTransition( } } - logger.warn(`[${requestId}] Workflow name was claimed during creation; retrying`, { + logger.warn('Workflow name was claimed during creation; retrying', { + requestId, name, attempt: attempt + 1, }) } } - logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) + logger.info(`Successfully created workflow ${workflowId}`, { requestId }) return { success: true, @@ -430,7 +432,7 @@ export async function performCreateWorkflow( } return result } catch (error) { - logger.error(`[${requestId}] Failed to create workflow`, { error }) + logger.error('Failed to create workflow', { requestId, error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -504,7 +506,8 @@ export async function updateWorkflowRecord( return { success: false, error: 'Workflow not found', errorCode: 'not_found' } } - logger.info(`[${requestId}] Successfully updated workflow ${params.workflowId}`, { + logger.info(`Successfully updated workflow ${params.workflowId}`, { + requestId, updates: updateData, }) @@ -550,7 +553,7 @@ export async function deleteWorkflowRecord( return { success: false, error: 'Workflow not found', errorCode: 'not_found' } } - logger.info(`[${requestId}] Successfully archived workflow ${workflowId}`) + logger.info(`Successfully archived workflow ${workflowId}`, { requestId }) return { success: true, archived: archiveResult.archived, @@ -610,7 +613,7 @@ export async function performRestoreWorkflow( } } - logger.info(`[${requestId}] Successfully restored workflow ${workflowId}`) + logger.info(`Successfully restored workflow ${workflowId}`, { requestId }) recordAudit({ workspaceId: restoreResult.workflow.workspaceId || null, @@ -628,7 +631,7 @@ export async function performRestoreWorkflow( return { success: true, workflow: restoreResult.workflow } } catch (error) { - logger.error(`[${requestId}] Failed to restore workflow ${workflowId}`, { error }) + logger.error(`Failed to restore workflow ${workflowId}`, { requestId, error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index f05c08fc4fb..57431e33e78 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -18,6 +18,7 @@ import { normalizeWorkflowEdgeTargetHandle, } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' +import { generateRequestId } from '@/lib/core/utils/request' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row' @@ -117,7 +118,7 @@ export async function duplicateWorkflow( description, workspaceId, folderId, - requestId = 'unknown', + requestId = generateRequestId(), newWorkflowId: clientNewWorkflowId, tx: providedTx, workflowIdMap, @@ -309,9 +310,9 @@ export async function duplicateWorkflow( }) await tx.insert(workflowBlocks).values(newBlocks) - logger.info( - `[${requestId}] Copied ${sourceBlocks.length} blocks with updated parent relationships` - ) + logger.info(`Copied ${sourceBlocks.length} blocks with updated parent relationships`, { + requestId, + }) } // Copy all edges from source workflow with updated block references @@ -368,7 +369,8 @@ export async function duplicateWorkflow( await tx.insert(workflowEdges).values(newEdges) } logger.info( - `[${requestId}] Copied ${newEdges.length}/${sourceEdges.length} edges with updated block references` + `Copied ${newEdges.length}/${sourceEdges.length} edges with updated block references`, + { requestId } ) } @@ -386,12 +388,14 @@ export async function duplicateWorkflow( if (!newSubflowId) { logger.warn( - `[${requestId}] Subflow ${subflow.id} (${subflow.type}) has no corresponding block, skipping` + `Subflow ${subflow.id} (${subflow.type}) has no corresponding block, skipping`, + { requestId } ) return null } - logger.info(`[${requestId}] Mapping subflow ${subflow.id} → ${newSubflowId}`, { + logger.info(`Mapping subflow ${subflow.id} → ${newSubflowId}`, { + requestId, subflowType: subflow.type, }) @@ -447,7 +451,8 @@ export async function duplicateWorkflow( } logger.info( - `[${requestId}] Copied ${newSubflows.length}/${sourceSubflows.length} subflows with updated block references and matching IDs` + `Copied ${newSubflows.length}/${sourceSubflows.length} subflows with updated block references and matching IDs`, + { requestId } ) } diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 131645d8499..5c480dfe5a9 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -244,7 +244,6 @@ export async function replaceWorkflowNormalizedState( input: ReplaceWorkflowNormalizedStateInput ): Promise { const { workflowId, workspaceId, attributedUserId, subjectUserId, state, requestId } = input - const logPrefix = requestId ? `[${requestId}] ` : '' /** * Hoisted ahead of the transaction even though the shared write checks it @@ -336,7 +335,8 @@ export async function replaceWorkflowNormalizedState( }) if (!saveResult.success) { - logger.error(`${logPrefix}Failed to save workflow ${workflowId} state`, { + logger.error(`Failed to save workflow ${workflowId} state`, { + requestId, error: saveResult.error, }) throw new WorkflowStatePersistenceError(saveResult.error ?? 'Unknown persistence failure') @@ -350,16 +350,17 @@ export async function replaceWorkflowNormalizedState( attributedUserId ) if (saved > 0) { - logger.info(`${logPrefix}Persisted ${saved} custom tool(s) to database`, { workflowId }) + logger.info(`Persisted ${saved} custom tool(s) to database`, { requestId, workflowId }) } if (errors.length > 0) { - logger.warn(`${logPrefix}Some custom tools failed to persist`, { errors, workflowId }) + logger.warn('Some custom tools failed to persist', { requestId, errors, workflowId }) } } catch (error) { - logger.error(`${logPrefix}Failed to persist custom tools`, { error, workflowId }) + logger.error('Failed to persist custom tools', { requestId, error, workflowId }) } } else { - logger.warn(`${logPrefix}Workflow has no workspaceId, skipping custom tools persistence`, { + logger.warn('Workflow has no workspaceId, skipping custom tools persistence', { + requestId, workflowId, }) } diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index c5e4239f84b..1c25c1e592b 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -70,14 +70,14 @@ export async function saveWorkflowNormalizedState(params: { const workflowData = authorization.workflow if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`) + logger.warn(`Workflow ${workflowId} not found for state update`, { requestId }) return { success: false, status: 404, error: 'Workflow not found' } } if (!authorization.allowed) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}` - ) + logger.warn(`User ${userId} denied permission to update workflow state ${workflowId}`, { + requestId, + }) return { success: false, status: authorization.status || 403, diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 6b9ea708fd3..eea98588911 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -396,7 +396,7 @@ export async function upsertSkills(params: { .where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId))) touched.push({ id: s.id, name: nextName, operation: 'updated' }) - logger.info(`[${requestId}] Updated skill ${s.id}`) + logger.info(`Updated skill ${s.id}`, { requestId }) continue } @@ -438,7 +438,7 @@ export async function upsertSkills(params: { }) touched.push({ id: newId, name: s.name, operation: 'created' }) - logger.info(`[${requestId}] Created skill "${s.name}"`) + logger.info(`Created skill "${s.name}"`, { requestId }) } if (params.returnSkills === false) { diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index 11ba07fe0f5..c7f37a45302 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -326,11 +326,9 @@ describe('createStreamingResponse', () => { expect(events).toContainEqual({ event: 'error', error: message }) expect(events.some((event) => event.event === 'final')).toBe(false) expect(workflowStreamingLogger.error).toHaveBeenCalledWith( - '[request-secret-failure] Stream error', - { - errorType: 'error', - hasStack: true, - } + 'Stream error', + { errorType: 'error', hasStack: true }, + { requestId: 'request-secret-failure' } ) const loggerPayload = JSON.stringify(workflowStreamingLogger.error.mock.calls) expect(loggerPayload).not.toContain(secret) @@ -377,8 +375,9 @@ describe('createStreamingResponse', () => { expect(events).toContainEqual({ event: 'stream_error', blockId: 'agent-1', error: message }) expect(workflowStreamingLogger.error).toHaveBeenCalledWith( - '[request-block-stream-failure] Error reading stream for block agent-1', - { errorType: 'error', hasStack: true } + 'Error reading stream for block agent-1', + { errorType: 'error', hasStack: true }, + { requestId: 'request-block-stream-failure' } ) const loggerPayload = JSON.stringify(workflowStreamingLogger.error.mock.calls) expect(loggerPayload).not.toContain(secret) @@ -606,8 +605,9 @@ describe('createStreamingResponse', () => { expect(events).toContainEqual({ event: 'error', blockId: 'block', error: message }) expect(workflowStreamingLogger.warn).toHaveBeenCalledWith( - '[request-selected-output-failure] Failed to materialize selected output', + 'Failed to materialize selected output', { + requestId: 'request-selected-output-failure', blockId: 'block', outputId: 'block_value', errorType: 'error', @@ -1439,13 +1439,13 @@ describe('createStreamingResponse agent-events-v1', () => { await vi.waitFor(() => expect(workflowStreamingLogger.info).toHaveBeenCalledWith( - '[request-client-cancel] Streaming execution aborted by client disconnect' + 'Streaming execution aborted by client disconnect', + { requestId: 'request-client-cancel' } ) ) - expect(workflowStreamingLogger.error).not.toHaveBeenCalledWith( - '[request-client-cancel] Stream error', - expect.anything() - ) + expect( + workflowStreamingLogger.error.mock.calls.some((call: unknown[]) => call[0] === 'Stream error') + ).toBe(false) }) it('thinking never enters streamedChunks / log content rewrite', async () => { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index 0aff9846de3..73f5d5d7d9d 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -403,12 +403,12 @@ async function buildMinimalResult( } if (isDangerousKey(blockId)) { - logger.warn(`[${requestId}] Blocked dangerous blockId: ${blockId}`) + logger.warn(`Blocked dangerous blockId: ${blockId}`, { requestId }) continue } if (isDangerousKey(path)) { - logger.warn(`[${requestId}] Blocked dangerous path: ${path}`) + logger.warn(`Blocked dangerous path: ${path}`, { requestId }) continue } @@ -636,7 +636,7 @@ export async function createStreamingResponse( const onStreamCallback = async (streamingExec: StreamingExecution) => { const blockId = streamingExec.blockId if (!blockId) { - logger.warn(`[${requestId}] Streaming execution missing blockId`) + logger.warn('Streaming execution missing blockId', { requestId }) return } @@ -727,8 +727,9 @@ export async function createStreamingResponse( } } catch (error) { logger.error( - `[${requestId}] Error reading stream for block ${blockId}`, - projectResolvedSecretDiagnosticError(error, undefined) + `Error reading stream for block ${blockId}`, + projectResolvedSecretDiagnosticError(error, undefined), + { requestId } ) const frame: ChatStreamStreamErrorFrame = { event: 'stream_error', @@ -863,7 +864,8 @@ export async function createStreamingResponse( }) } } catch (error) { - logger.warn(`[${requestId}] Failed to materialize selected output`, { + logger.warn('Failed to materialize selected output', { + requestId, blockId: selectedOutputBlockId, outputId: descriptor.outputId, ...projectResolvedSecretDiagnosticError(error, undefined), @@ -907,7 +909,8 @@ export async function createStreamingResponse( !requestAborted ) { const timeoutErrorMessage = getTimeoutErrorMessage(timeoutController.timeoutMs) - logger.info(`[${requestId}] Streaming execution timed out`, { + logger.info('Streaming execution timed out', { + requestId, timeoutMs: timeoutController.timeoutMs, }) if (result._streamingMetadata?.loggingSession) { @@ -916,7 +919,7 @@ export async function createStreamingResponse( const frame: ChatStreamErrorFrame = { event: 'error', error: timeoutErrorMessage } enqueue(encodeSSE(frame)) } else if (result.status === 'cancelled' && requestAborted) { - logger.info(`[${requestId}] Streaming execution aborted by client disconnect`) + logger.info('Streaming execution aborted by client disconnect', { requestId }) if (result._streamingMetadata?.loggingSession) { // LoggingSession has no cancelled status; match workflow execute route wording. await result._streamingMetadata.loggingSession.markAsFailed('Client cancelled request') @@ -972,10 +975,9 @@ export async function createStreamingResponse( if (!streamCancelled) controller.close() } catch (error) { - logger.error( - `[${requestId}] Stream error`, - projectResolvedSecretDiagnosticError(error, undefined) - ) + logger.error('Stream error', projectResolvedSecretDiagnosticError(error, undefined), { + requestId, + }) const errorMessage = streamConfig.selectedOutputs?.length && isExecutionResourceLimitError(error) ? SELECTED_OUTPUT_TOO_LARGE_MESSAGE @@ -999,8 +1001,9 @@ export async function createStreamingResponse( async cancel(reason) { streamCancelled = true logger.info( - `[${requestId}] Streaming response cancelled`, - projectResolvedSecretDiagnosticError(reason, undefined) + 'Streaming response cancelled', + projectResolvedSecretDiagnosticError(reason, undefined), + { requestId } ) requestAborted = true stopKeepalive() @@ -1012,8 +1015,9 @@ export async function createStreamingResponse( await cleanupExecutionBase64Cache(executionId) } catch (error) { logger.error( - `[${requestId}] Failed to cleanup base64 cache`, - projectResolvedSecretDiagnosticError(error, undefined) + 'Failed to cleanup base64 cache', + projectResolvedSecretDiagnosticError(error, undefined), + { requestId } ) } } diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 692403d0bc7..b853b0adaa4 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -384,7 +384,7 @@ export async function validateWorkflowPermissions( ) { const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] No authenticated user session for workflow ${action}`) + logger.warn(`No authenticated user session for workflow ${action}`, { requestId }) return { error: { message: 'Unauthorized', status: 401 }, session: null, @@ -399,7 +399,7 @@ export async function validateWorkflowPermissions( }) if (!authorization.workflow) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) + logger.warn(`Workflow ${workflowId} not found`, { requestId }) return { error: { message: 'Workflow not found', status: 404 }, session: null, @@ -410,13 +410,11 @@ export async function validateWorkflowPermissions( if (!authorization.allowed) { const message = authorization.message || `Unauthorized: Access denied to ${action} this workflow` - logger.warn( - `[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId}`, - { - action, - workflowId, - } - ) + logger.warn(`User ${session.user.id} unauthorized to ${action} workflow ${workflowId}`, { + requestId, + action, + workflowId, + }) return { error: { message, status: authorization.status }, session: null, diff --git a/apps/sim/lib/workspace-events/emitter.ts b/apps/sim/lib/workspace-events/emitter.ts index ce672aef57a..19c56f7fa85 100644 --- a/apps/sim/lib/workspace-events/emitter.ts +++ b/apps/sim/lib/workspace-events/emitter.ts @@ -46,28 +46,28 @@ export async function dispatchSimEvent( subscription: SimSubscription, payload: SimEventPayload ): Promise { - const requestId = generateShortId() + const dispatchId = generateShortId() try { const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') const result = await processPolledWebhookEvent( subscription.webhook, subscription.workflow, payload, - requestId + dispatchId ) if (!result.success) { logger.error( - `[${requestId}] Failed to fire sim trigger for workflow ${subscription.workflow.id}:`, + `Failed to fire sim trigger for workflow ${subscription.workflow.id}:`, result.statusCode, - result.error + result.error, + { dispatchId } ) } } catch (error) { - logger.error( - `[${requestId}] Error firing sim trigger for workflow ${subscription.workflow.id}:`, - error - ) + logger.error(`Error firing sim trigger for workflow ${subscription.workflow.id}:`, error, { + dispatchId, + }) } } diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index 831e57d0d4d..c3824179c7c 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -101,7 +101,7 @@ export async function archiveWorkspace( }) if (!outcome) return { archived: false } - logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`) + logger.info(`Archived workspace ${workspaceId}`, { requestId: options.requestId }) options.deliverStops?.(outcome.effects.stoppedRuns) await finishEnvironmentArchive(outcome.effects, options.requestId) @@ -172,9 +172,7 @@ export async function finishEnvironmentArchive( cleanupErrors.push(error) return } - logger.warn(`[${requestId}] Post-archive notification failed for workflow ${row.id}`, { - error, - }) + logger.warn(`Post-archive notification failed for workflow ${row.id}`, { requestId, error }) }) ) if (cleanupErrors.length > 0) throw cleanupErrors[0] @@ -185,7 +183,8 @@ export async function finishEnvironmentArchive( try { mcpPubSub.publishWorkflowToolsChanged({ serverId, workspaceId }) } catch (error) { - logger.warn(`[${requestId}] MCP tools-changed publish failed for server ${serverId}`, { + logger.warn(`MCP tools-changed publish failed for server ${serverId}`, { + requestId, error, }) } diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 7e576182011..cc068c00f56 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -643,6 +643,20 @@ const nextConfig: NextConfig = { } ) + /** Readers and agents guess the product's full name; the page's slug is `microsoft-copilot`. */ + redirects.push( + { + source: '/comparisons/microsoft-copilot-studio', + destination: '/comparisons/microsoft-copilot', + permanent: true, + }, + { + source: '/comparisons/copilot-studio', + destination: '/comparisons/microsoft-copilot', + permanent: true, + } + ) + /** * Stray crawler/artifact URLs picked up in an external SEO audit — no * page ever existed at these paths, but they were indexed or linked diff --git a/apps/sim/package.json b/apps/sim/package.json index a57fddab704..e60bf5a6c14 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -79,6 +79,8 @@ "@aws-sdk/client-iam": "3.1150.0", "@aws-sdk/client-identitystore": "3.1150.0", "@aws-sdk/client-lambda": "3.1150.0", + "@aws-sdk/client-marketplace-entitlement-service": "3.1150.0", + "@aws-sdk/client-marketplace-metering": "3.1150.0", "@aws-sdk/client-organizations": "3.1150.0", "@aws-sdk/client-rds-data": "3.1150.0", "@aws-sdk/client-s3": "3.1150.0", @@ -89,6 +91,7 @@ "@aws-sdk/client-sso-admin": "3.1150.0", "@aws-sdk/client-sts": "3.1150.0", "@aws-sdk/client-textract": "3.1150.0", + "@aws-sdk/credential-providers": "3.1150.0", "@aws-sdk/lib-dynamodb": "3.1150.0", "@aws-sdk/s3-request-presigner": "3.1150.0", "@azure/communication-email": "1.1.0", diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index e24ba48893f..9592791c333 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -47,6 +47,7 @@ export type AttachmentProvider = | 'meta' | 'zai' | 'kimi' + | 'thinkingmachines' export interface PreparedProviderAttachment { file: UserFile @@ -161,6 +162,7 @@ const UNSUPPORTED_FILE_PROVIDERS = new Set([ 'nvidia', 'meta', 'zai', + 'thinkingmachines', ]) const PROVIDER_SUPPORTED_LABELS: Record = { @@ -186,6 +188,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { meta: 'no file attachments in the current API adapter', zai: 'no file attachments in the current API adapter', kimi: 'images through image_url message parts on multimodal models', + thinkingmachines: 'no file attachments in the current API adapter', } export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { @@ -211,6 +214,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'meta') return 'meta' if (providerId === 'zai') return 'zai' if (providerId === 'kimi') return 'kimi' + if (providerId === 'thinkingmachines') return 'thinkingmachines' return null } @@ -427,6 +431,7 @@ function isMimeTypeSupportedByProvider( case 'nvidia': case 'meta': case 'zai': + case 'thinkingmachines': return false default: { const _exhaustive: never = provider diff --git a/apps/sim/providers/file-attachments.server.ts b/apps/sim/providers/file-attachments.server.ts index 1f44fd00cd5..9940801c1a5 100644 --- a/apps/sim/providers/file-attachments.server.ts +++ b/apps/sim/providers/file-attachments.server.ts @@ -100,7 +100,8 @@ export async function attachLargeFileRemoteUrls( if (!StorageService.hasCloudStorage()) { logger.warn( - `[${requestId}] Sending "${file.name}" inline for "${providerId}": the large-file path needs cloud storage, which is not configured` + `Sending "${file.name}" inline for "${providerId}": the large-file path needs cloud storage, which is not configured`, + { requestId } ) continue } diff --git a/apps/sim/providers/history-adapters.ts b/apps/sim/providers/history-adapters.ts index 61a0bde4d15..82108983141 100644 --- a/apps/sim/providers/history-adapters.ts +++ b/apps/sim/providers/history-adapters.ts @@ -117,6 +117,7 @@ export const providerHistoryProtocols: Record< together: 'chat-completions', baseten: 'chat-completions', nebius: 'chat-completions', + thinkingmachines: 'chat-completions', vllm: 'chat-completions', litellm: 'chat-completions', } diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 42ac02cf3b6..6e6c89100f1 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -36,6 +36,7 @@ import { ZaiIcon, } from '@/components/icons' import { NebiusIcon } from '@/components/icons/nebius' +import { ThinkingMachinesIcon } from '@/components/icons/thinking-machines' import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref' import type { ModelPricing, ProviderId } from '@/providers/types' @@ -441,6 +442,42 @@ export const PROVIDER_DEFINITIONS: Record = { }, ], }, + thinkingmachines: { + id: 'thinkingmachines', + name: 'Thinking Machines', + description: 'Inkling models via Tinker serverless inference', + defaultModel: 'thinkingmachines/inkling', + modelPatterns: [/^thinkingmachines\//], + icon: ThinkingMachinesIcon, + color: '#111111', + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + forcedToolUse: false, + }, + models: [ + { + id: 'thinkingmachines/inkling', + pricing: { input: 1.0, cachedInput: 0.17, output: 4.05, updatedAt: '2026-11-05' }, + capabilities: {}, + contextWindow: 262144, + releaseDate: '2026-07-16', + }, + { + id: 'thinkingmachines/inkling-2-small', + pricing: { input: 0.3, cachedInput: 0.03, output: 1.0, updatedAt: '2026-11-05' }, + capabilities: {}, + contextWindow: 262144, + }, + { + id: 'thinkingmachines/inkling-small', + pricing: { input: 0.3, cachedInput: 0.06, output: 1.2, updatedAt: '2026-11-05' }, + capabilities: {}, + contextWindow: 262144, + releaseDate: '2026-07-31', + }, + ], + }, together: { id: 'together', fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, diff --git a/apps/sim/providers/openai-compat/chat-completions.ts b/apps/sim/providers/openai-compat/chat-completions.ts index 5e20ba45b75..f8e3e99f6ef 100644 --- a/apps/sim/providers/openai-compat/chat-completions.ts +++ b/apps/sim/providers/openai-compat/chat-completions.ts @@ -59,7 +59,7 @@ export type ChatCompletionPayload = ChatCompletionCreateParamsNonStreaming & { } interface ChatCompletionExecutionConfig { - providerId: 'baseten' | 'together' | 'fireworks' | 'openrouter' | 'nebius' + providerId: 'baseten' | 'together' | 'fireworks' | 'openrouter' | 'nebius' | 'thinkingmachines' providerName: string client: OpenAI requestedModel: string diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 3a3f2065129..9d9fab67fb1 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -21,6 +21,7 @@ import { ollamaCloudProvider } from '@/providers/ollama-cloud' import { openaiProvider } from '@/providers/openai' import { openRouterProvider } from '@/providers/openrouter' import { sakanaProvider } from '@/providers/sakana' +import { thinkingMachinesProvider } from '@/providers/thinkingmachines' import { togetherProvider } from '@/providers/together' import type { ProviderConfig, ProviderId } from '@/providers/types' import { typesafeProvider } from '@/providers/typesafe' @@ -57,6 +58,7 @@ const providerRegistry: Record = { together: togetherProvider, baseten: basetenProvider, nebius: nebiusProvider, + thinkingmachines: thinkingMachinesProvider, ollama: ollamaProvider, 'ollama-cloud': ollamaCloudProvider, bedrock: bedrockProvider, diff --git a/apps/sim/providers/thinkingmachines/index.live.test.ts b/apps/sim/providers/thinkingmachines/index.live.test.ts new file mode 100644 index 00000000000..b1d3fa5b1f3 --- /dev/null +++ b/apps/sim/providers/thinkingmachines/index.live.test.ts @@ -0,0 +1,142 @@ +import { writeFile } from 'node:fs/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeProviderRequest } from '@/providers' +import { getProviderModels } from '@/providers/models' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +vi.unmock('@/tools/registry') +vi.unmock('@/tools/metadata') +vi.unmock('@/tools/metadata-outputs') + +const results: Array<{ + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string +}> = [] +const apiKey = process.env.THINKINGMACHINES_API_KEY +const reportPath = process.env.THINKINGMACHINES_REPORT_PATH +const model = 'thinkingmachines/inkling-2-small' + +async function check(name: string, run: () => Promise) { + const start = Date.now() + try { + await run() + results.push({ name, status: 'passed', durationMs: Date.now() - start }) + } catch (error) { + results.push({ + name, + status: 'failed', + durationMs: Date.now() - start, + error: getErrorMessage(error), + }) + throw error + } +} + +async function execute(request: Partial = {}) { + expect(vi.isMockFunction(globalThis.fetch)).toBe(false) + return executeProviderRequest('thinkingmachines', { + model, + apiKey, + temperature: 0, + maxTokens: 2048, + abortSignal: AbortSignal.timeout(90_000), + messages: [{ role: 'user', content: 'Reply with only the result of 6 times 7.' }], + ...request, + }) +} + +beforeAll(async () => { + await check('configuration', async () => { + if (!reportPath) + throw new Error('Set THINKINGMACHINES_REPORT_PATH to a writable JSON report path') + if (!apiKey) throw new Error('Set THINKINGMACHINES_API_KEY to a funded Tinker API key') + }) +}) + +afterAll(async () => { + if (reportPath) + await writeFile( + reportPath, + JSON.stringify({ recordedAt: new Date().toISOString(), results }, null, 2) + ) +}) + +describe('Thinking Machines live acceptance', () => { + it('rejects invalid credentials over the real API', async () => { + await check('invalid-credentials', async () => { + await expect(execute({ apiKey: 'invalid-tinker-test-key' })).rejects.toThrow(/401|403/) + }) + }, 60_000) + + it.each(getProviderModels('thinkingmachines'))( + 'serves catalog model %s', + async (catalogModel) => { + await check(`catalog:${catalogModel}`, async () => { + const result = (await execute({ model: catalogModel })) as ProviderResponse + expect(result.content).toContain('42') + expect(result.content).not.toMatch(//i) + expect(result.tokens?.total).toBeGreaterThan(0) + expect(result.model).toBe(catalogModel) + }) + }, + 120_000 + ) + + it('streams the answer and final usage', async () => { + await check('stream', async () => { + const result = (await execute({ stream: true })) as StreamingExecution + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + const execution = await result.execution + expect(execution.output.content).toContain('42') + expect(execution.output.tokens?.total).toBeGreaterThan(0) + }) + }, 60_000) + + it('returns a JSON-schema answer through the prompt fallback', async () => { + await check('structured-output', async () => { + const result = (await execute({ + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'integer' } }, + required: ['answer'], + additionalProperties: false, + }, + }, + })) as ProviderResponse + expect(JSON.parse(result.content)).toEqual({ answer: 42 }) + }) + }, 60_000) + + it('executes an HTTP tool and feeds its result back into the answer', async () => { + await check('tool-loop', async () => { + const result = (await execute({ + messages: [ + { + role: 'user', + content: 'Fetch https://example.com using the tool, then report the HTTP status code.', + }, + ], + tools: [ + { + id: 'http_request', + description: 'Fetch the example page', + usageControl: 'auto', + params: { url: 'https://example.com', method: 'GET' }, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + })) as ProviderResponse + expect(result.toolCalls?.some((call) => call.name === 'http_request' && call.success)).toBe( + true + ) + expect(result.content).toContain('200') + }) + }, 120_000) +}) diff --git a/apps/sim/providers/thinkingmachines/index.ts b/apps/sim/providers/thinkingmachines/index.ts new file mode 100644 index 00000000000..305ce26f07d --- /dev/null +++ b/apps/sim/providers/thinkingmachines/index.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import OpenAI from 'openai' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import type { StreamingExecution } from '@/executor/types' +import { + getProviderDefaultModel, + getProviderModels, + supportsForcedToolUse, +} from '@/providers/models' +import { + type ChatCompletionPayload, + executeChatCompletionRequest, +} from '@/providers/openai-compat/chat-completions' +import { openAICompatTransport } from '@/providers/transport' +import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' +import { generateSchemaInstructions } from '@/providers/utils' + +const logger = createLogger('ThinkingMachinesProvider') + +const TINKER_BASE_URL = 'https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1' + +/** Catalog ids mapped to the base model ids Tinker's OpenAI-compatible endpoint samples. */ +const TINKER_MODELS: Record = { + 'thinkingmachines/inkling': 'thinkingmachines/Inkling', + 'thinkingmachines/inkling-small': 'thinkingmachines/Inkling-Small', + 'thinkingmachines/inkling-2-small': 'thinkingmachines/Inkling-2-Small', +} + +/** + * Resolves the id sent to Tinker. Catalog models map to Tinker's base id; a + * `thinkingmachines/tinker://…` id samples a user's own Tinker checkpoint. + */ +function resolveTinkerModel(model: string): string { + const tinkerModel = TINKER_MODELS[model.toLowerCase()] + if (tinkerModel) return tinkerModel + const remainder = model.replace(/^thinkingmachines\//i, '') + return remainder.startsWith('tinker://') ? remainder : model +} + +/** Tinker documents no response_format support, so schemas travel as prompt instructions. */ +async function applyResponseFormat( + _payload: ChatCompletionPayload, + messages: ChatCompletionMessageParam[], + responseFormat: NonNullable +): Promise { + return [ + ...messages, + { + role: 'user', + content: generateSchemaInstructions(responseFormat.schema, responseFormat.name), + }, + ] +} + +export const thinkingMachinesProvider: ProviderConfig = { + id: 'thinkingmachines', + name: 'Thinking Machines', + description: 'Inkling models via Tinker serverless inference', + version: '1.0.0', + models: getProviderModels('thinkingmachines'), + defaultModel: getProviderDefaultModel('thinkingmachines'), + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) throw new Error('API key is required for Thinking Machines') + const client = new OpenAI({ + ...openAICompatTransport(), + apiKey: request.apiKey, + baseURL: TINKER_BASE_URL, + }) + const tools = supportsForcedToolUse(request.model) + ? request.tools + : request.tools?.map((tool) => + tool.usageControl === 'force' ? { ...tool, usageControl: 'auto' as const } : tool + ) + return executeChatCompletionRequest( + { ...request, tools }, + { + providerId: 'thinkingmachines', + providerName: 'Thinking Machines', + client, + requestedModel: resolveTinkerModel(request.model), + reportedModel: request.model, + logger, + applyResponseFormat, + reasoningFields: ['reasoning_content'], + recordPendingUsage: true, + } + ) + }, +} diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index b0c68a388c3..3be510f8771 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -29,6 +29,7 @@ export type ProviderId = | 'together' | 'baseten' | 'nebius' + | 'thinkingmachines' | 'vllm' | 'litellm' | 'bedrock' diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index dfc8223f782..f3706baa456 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -12,6 +12,7 @@ vi.mock('@/lib/internal/workflows/read-tool-enrichment', () => ({ })) import { getParamsForToolOperation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' +import { GaugeBlock } from '@/blocks/blocks/gauge' import { GreenhouseV2Block } from '@/blocks/blocks/greenhouse' import { IntuneBlock } from '@/blocks/blocks/intune' import { MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel' @@ -46,6 +47,19 @@ import { transformBlockTool, } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' +import { gaugeGetQueryMetadataTool } from '@/tools/gauge/get_query_metadata' +import { gaugeListAnswersTool } from '@/tools/gauge/list_answers' +import { gaugeListCitationsTool } from '@/tools/gauge/list_citations' +import { gaugeListPromptsTool } from '@/tools/gauge/list_prompts' +import { gaugeQueryTool } from '@/tools/gauge/query' +import type { + GaugeGetQueryMetadataParams, + GaugeListAnswersParams, + GaugeListPromptsParams, + GaugeQueryParams, + GaugeUpdatePromptParams, +} from '@/tools/gauge/types' +import { gaugeUpdatePromptTool } from '@/tools/gauge/update_prompt' import { greenhouseConvertProspectV2Tool } from '@/tools/greenhouse/convert_prospect_v2' import { greenhouseCreateApplicationV2Tool } from '@/tools/greenhouse/create_application_v2' import { greenhouseHireApplicationV2Tool } from '@/tools/greenhouse/hire_application_v2' @@ -79,6 +93,215 @@ const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key') const originalRequire = module.require describe('integration filters through agent execution', () => { + it.each([ + { tool: gaugeListAnswersTool, field: 'topicFilterSelector' }, + { tool: gaugeListAnswersTool, field: 'manualTopicFilter' }, + { tool: gaugeListCitationsTool, field: 'topicFilterSelector' }, + { tool: gaugeListCitationsTool, field: 'manualTopicFilter' }, + ])('ignores stale $field aliases for $tool.id', async ({ tool, field }) => { + const providerTool = await transformBlockTool( + { + type: 'gauge', + params: { + org: 'example', + category: 'example', + [field]: 'top_stale', + topic: '["top_active"]', + promptTrackingType: 'branded_own', + trackingType: 'all', + }, + }, + { selectedOperation: tool.id, getAllBlocks: () => [GaugeBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Gauge agent tool was not created') + const { executionParams } = prepareToolExecution(providerTool, {}, {}, undefined) + const params = { ...executionParams, accessToken: 'fixture-token' } as GaugeListAnswersParams + const url = typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + expect(new URL(url).searchParams.getAll('topic')).toEqual(['top_active']) + expect(new URL(url).searchParams.get('trackingType')).toBe('all') + }) + + it.each(['answers', 'all'])( + 'ignores a stale %s metadata selection when querying a different dataset', + async (metadataDataset) => { + const tool = gaugeQueryTool + const providerTool = await transformBlockTool( + { + type: 'gauge', + params: { + org: 'example', + dataset: 'citations', + metrics: '["citations"]', + metadataDataset, + }, + }, + { selectedOperation: tool.id, getAllBlocks: () => [GaugeBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Gauge agent tool was not created') + const { executionParams } = prepareToolExecution(providerTool, {}, {}, undefined) + const params = { ...executionParams, accessToken: 'fixture-token' } as GaugeQueryParams + expect(tool.request.body?.(params)).toMatchObject({ dataset: 'citations' }) + } + ) + + it('clears an incompatible Gauge topic array when switching an agent to List Prompts', async () => { + const tool = gaugeListPromptsTool + vi.spyOn(blockRegistry, 'getBlock').mockReturnValue(GaugeBlock) + const params = getParamsForToolOperation( + { type: 'gauge', params: { org: 'example', category: 'example', topic: '["top_stale"]' } }, + tool + ) + const providerTool = await transformBlockTool( + { type: 'gauge', params }, + { selectedOperation: tool.id, getAllBlocks: () => [GaugeBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Gauge agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { topic: 'top_model' }, + {}, + undefined + ) + const requestParams = { + ...executionParams, + accessToken: 'fixture-token', + } as GaugeListPromptsParams + const url = + typeof tool.request.url === 'function' ? tool.request.url(requestParams) : tool.request.url + expect(new URL(url).searchParams.get('topic')).toBe('top_model') + }) + + it.each(['configured', 'model'] as const)( + 'retains the %s Gauge topic filter without a stored operation', + async (source) => { + const tool = gaugeListPromptsTool + vi.spyOn(toolMetadata, 'getToolMetadata').mockReturnValue(tool) + const field = getSubBlocksForToolInput( + tool.id, + 'gauge', + { operation: tool.id }, + undefined, + GaugeBlock + )?.subBlocks.find((item) => item.canonicalParamId === 'topicFilter') + if (!field) throw new Error('Gauge topic filter control is unavailable') + const providerTool = await transformBlockTool( + { + type: 'gauge', + params: { + org: 'example', + category: 'example', + ...(source === 'configured' ? { [field.id]: 'top_configured' } : {}), + }, + }, + { selectedOperation: tool.id, getAllBlocks: () => [GaugeBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Gauge agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { topic: 'top_model' }, + {}, + undefined + ) + const params = { ...executionParams, accessToken: 'fixture-token' } as GaugeListPromptsParams + const url = + typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + expect(new URL(url).searchParams.get('topic')).toBe( + source === 'configured' ? 'top_configured' : 'top_model' + ) + } + ) + + it.each(['configured', 'unchanged', 'model'] as const)( + 'retains %s Gauge tracking intent without a stored operation', + async (source) => { + const tool = gaugeUpdatePromptTool + vi.spyOn(toolMetadata, 'getToolMetadata').mockReturnValue(tool) + const field = getSubBlocksForToolInput( + tool.id, + 'gauge', + { operation: tool.id }, + undefined, + GaugeBlock + )?.subBlocks.find((item) => item.id === 'promptTrackingType') + if (!field && source !== 'model') throw new Error('Gauge tracking control is unavailable') + const providerTool = await transformBlockTool( + { + type: 'gauge', + params: { + org: 'example', + category: 'example', + promptId: 'prompt_example', + ...(field && source !== 'model' + ? { [field.id]: source === 'unchanged' ? 'unchanged' : 'visibility' } + : {}), + }, + }, + { selectedOperation: tool.id, getAllBlocks: () => [GaugeBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Gauge agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { trackingType: 'branded_own' }, + {}, + undefined + ) + const params = { ...executionParams, accessToken: 'fixture-token' } as GaugeUpdatePromptParams + expect(tool.request.body?.(params)).toEqual({ + topicId: undefined, + trackingType: + source === 'unchanged' + ? undefined + : source === 'configured' + ? 'visibility' + : 'branded_own', + }) + } + ) + + it.each(['configured', 'all', 'model'] as const)( + 'retains %s Gauge catalog intent without a stored operation', + async (source) => { + const tool = gaugeGetQueryMetadataTool + vi.spyOn(toolMetadata, 'getToolMetadata').mockReturnValue(tool) + const field = getSubBlocksForToolInput( + tool.id, + 'gauge', + { operation: tool.id }, + undefined, + GaugeBlock + )?.subBlocks.find((item) => item.id === 'metadataDataset') + if (!field && source !== 'model') throw new Error('Gauge catalog control is unavailable') + const providerTool = await transformBlockTool( + { + type: 'gauge', + params: { + org: 'example', + ...(field && source !== 'model' + ? { [field.id]: source === 'all' ? 'all' : 'citations' } + : {}), + }, + }, + { selectedOperation: tool.id, getAllBlocks: () => [GaugeBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Gauge agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { dataset: 'aiTraffic' }, + {}, + undefined + ) + const params = { + ...executionParams, + accessToken: 'fixture-token', + } as GaugeGetQueryMetadataParams + const url = + typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + expect(new URL(url).searchParams.get('dataset')).toBe( + source === 'all' ? null : source === 'configured' ? 'citations' : 'aiTraffic' + ) + } + ) + it.each([ { operation: 'list_jobs', diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index cdb4ac188fd..07ef9be1b39 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -23,7 +23,7 @@ import { } from '@/lib/workflows/subblocks/visibility' import { resolveBlockToolId } from '@/lib/workflows/tool-input/identity' import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/custom/build-config' -import type { SubBlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext, SubBlockConfig } from '@/blocks/types' import { isCustomTool } from '@/executor/constants' import { findProviderFromModel, @@ -164,6 +164,7 @@ export const providers: Record = { together: buildProviderMetadata('together'), baseten: buildProviderMetadata('baseten'), nebius: buildProviderMetadata('nebius'), + thinkingmachines: buildProviderMetadata('thinkingmachines'), } export function updateOllamaProviderModels(models: string[]): void { @@ -523,7 +524,10 @@ function buildCustomBlockInputMappingSchema( } } -type BlockToolParamsFn = (params: Record) => Record +type BlockToolParamsFn = ( + params: Record, + context?: BlockToolParamsContext +) => Record /** * Builds the transform that turns a tool row's stored sub-block values into the @@ -547,6 +551,7 @@ type BlockToolParamsFn = (params: Record) => Record * none of it, which is why it needed a new version rather than a fix in place. */ export function buildBlockToolParamsTransform(config: { + operation?: string blockSubBlocks: SubBlockConfig[] | undefined blockParamsFn: BlockToolParamsFn | undefined blockInputDefs: Record | undefined @@ -558,6 +563,7 @@ export function buildBlockToolParamsTransform(config: { jsonShapedParamKeys: string[] } { const { + operation, blockSubBlocks, blockParamsFn, blockInputDefs, @@ -613,7 +619,7 @@ export function buildBlockToolParamsTransform(config: { } if (blockParamsFn) { - const transformed = blockParamsFn(result) + const transformed = blockParamsFn(result, { operation }) result = { ...result, ...transformed } } @@ -893,6 +899,9 @@ export async function transformBlockTool( } const { paramsTransform, jsonShapedParamKeys } = buildBlockToolParamsTransform({ + operation: + selectedOperation ?? + (typeof userProvidedParams.operation === 'string' ? userProvidedParams.operation : undefined), blockSubBlocks: blockDef?.subBlocks, blockParamsFn: blockDef?.tools?.config?.params as BlockToolParamsFn | undefined, blockInputDefs: blockDef?.inputs as Record | undefined, diff --git a/apps/sim/public/library/ai-agent-builders-claude-gemini-gpt-open-models/cover.jpg b/apps/sim/public/library/ai-agent-builders-claude-gemini-gpt-open-models/cover.jpg new file mode 100644 index 00000000000..0382f4a55a1 Binary files /dev/null and b/apps/sim/public/library/ai-agent-builders-claude-gemini-gpt-open-models/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/cover.jpg b/apps/sim/public/library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/cover.jpg new file mode 100644 index 00000000000..726b25ecdc6 Binary files /dev/null and b/apps/sim/public/library/best-ai-agents-content-repurposing-fact-checking-news-monitoring/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-agents-sales-call-summaries-crm-updates-2026/cover.jpg b/apps/sim/public/library/best-ai-agents-sales-call-summaries-crm-updates-2026/cover.jpg new file mode 100644 index 00000000000..3a223211fc5 Binary files /dev/null and b/apps/sim/public/library/best-ai-agents-sales-call-summaries-crm-updates-2026/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-workflow-tools-google-drive-document-automation/cover.jpg b/apps/sim/public/library/best-ai-workflow-tools-google-drive-document-automation/cover.jpg new file mode 100644 index 00000000000..a9f0e432d26 Binary files /dev/null and b/apps/sim/public/library/best-ai-workflow-tools-google-drive-document-automation/cover.jpg differ diff --git a/apps/sim/public/library/best-free-ai-agent-builders-2026/cover.jpg b/apps/sim/public/library/best-free-ai-agent-builders-2026/cover.jpg new file mode 100644 index 00000000000..3ea92b65ac1 Binary files /dev/null and b/apps/sim/public/library/best-free-ai-agent-builders-2026/cover.jpg differ diff --git a/apps/sim/public/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/cover.jpg b/apps/sim/public/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/cover.jpg new file mode 100644 index 00000000000..5ada29ce24b Binary files /dev/null and b/apps/sim/public/library/best-llm-orchestration-tools-multi-provider-call-chaining-2026/cover.jpg differ diff --git a/apps/sim/public/library/best-tines-alternatives-beyond-secops-2026/cover.jpg b/apps/sim/public/library/best-tines-alternatives-beyond-secops-2026/cover.jpg new file mode 100644 index 00000000000..70ae3431f32 Binary files /dev/null and b/apps/sim/public/library/best-tines-alternatives-beyond-secops-2026/cover.jpg differ diff --git a/apps/sim/public/library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/cover.jpg b/apps/sim/public/library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/cover.jpg new file mode 100644 index 00000000000..9ea57b378e8 Binary files /dev/null and b/apps/sim/public/library/microsoft-365-ai-agents-outlook-teams-sharepoint-excel/cover.jpg differ diff --git a/apps/sim/public/library/salesforce-mcp-server-ai-agent-sim-setup/cover.jpg b/apps/sim/public/library/salesforce-mcp-server-ai-agent-sim-setup/cover.jpg new file mode 100644 index 00000000000..295ab245544 Binary files /dev/null and b/apps/sim/public/library/salesforce-mcp-server-ai-agent-sim-setup/cover.jpg differ diff --git a/apps/sim/scripts/test-firecrawl-enrichment-e2e.ts b/apps/sim/scripts/test-firecrawl-enrichment-e2e.ts new file mode 100644 index 00000000000..88195e63b8c --- /dev/null +++ b/apps/sim/scripts/test-firecrawl-enrichment-e2e.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { companyDomainEnrichment } from '@/enrichments/company-domain' +import { companyInfoEnrichment } from '@/enrichments/company-info' +import { linkedinProfileEnrichment } from '@/enrichments/linkedin-profile' +import { phoneNumberEnrichment } from '@/enrichments/phone-number' +import type { EnrichmentConfig } from '@/enrichments/types' +import { workEmailEnrichment } from '@/enrichments/work-email' +import { firecrawlEnrichTool } from '@/tools/firecrawl/enrich' +import type { FirecrawlEnrichParams } from '@/tools/firecrawl/types' + +const logger = createLogger('FirecrawlEnrichmentE2E') + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Set ${name} before running the live enrichment checks`) + return value +} + +const reportPath = requiredEnv('FIRECRAWL_ENRICHMENT_REPORT_PATH') +const apiKey = requiredEnv('FIRECRAWL_API_KEY') +const person = { + fullName: requiredEnv('FIRECRAWL_TEST_FULL_NAME'), + companyDomain: requiredEnv('FIRECRAWL_TEST_EMPLOYER_DOMAIN'), + linkedinUrl: requiredEnv('FIRECRAWL_TEST_LINKEDIN_URL'), + email: requiredEnv('FIRECRAWL_TEST_EMAIL'), +} +const profileUrl = new URL( + /^https?:\/\//i.test(person.linkedinUrl) + ? person.linkedinUrl + : `https://${person.linkedinUrl.replace(/^\/+/, '')}` +) +const [profileKind, profileSlug] = profileUrl.pathname.split('/').filter(Boolean) +assert.match(profileUrl.hostname, /(^|\.)linkedin\.com$/i) +assert.equal(profileKind?.toLowerCase(), 'in') +assert.ok(profileSlug) +const expectedLinkedinUrl = `https://www.linkedin.com/in/${profileSlug}` + +interface LiveCheck { + name: string + config: EnrichmentConfig + inputs: Record + verify?: (output: Record) => void + expectNoMatch?: boolean +} + +const checks: LiveCheck[] = [ + { + name: 'single-part-name email lookup returns a clean miss', + config: workEmailEnrichment, + inputs: { fullName: 'AlexandriaNoMatchFixture', companyDomain: 'example.com' }, + expectNoMatch: true, + }, + { + name: 'single-part-name phone lookup returns a clean miss', + config: phoneNumberEnrichment, + inputs: { fullName: 'AlexandriaNoMatchFixture', companyDomain: 'example.com' }, + expectNoMatch: true, + }, + { + name: 'work email from name and company', + config: workEmailEnrichment, + inputs: { fullName: person.fullName, companyDomain: person.companyDomain }, + verify: (output) => + assert.equal(String(output.email).toLowerCase(), person.email.toLowerCase()), + }, + { + name: 'mobile from LinkedIn', + config: phoneNumberEnrichment, + inputs: { fullName: person.fullName, linkedinUrl: person.linkedinUrl }, + verify: (output) => assert.match(String(output.phone), /^\+?[\d\s()-]{7,}$/), + }, + { + name: 'LinkedIn from email', + config: linkedinProfileEnrichment, + inputs: { fullName: person.fullName, email: person.email }, + verify: (output) => assert.equal(output.linkedinUrl, expectedLinkedinUrl), + }, + { + name: 'LinkedIn from name and company', + config: linkedinProfileEnrichment, + inputs: { fullName: person.fullName, companyDomain: person.companyDomain }, + verify: (output) => assert.equal(output.linkedinUrl, expectedLinkedinUrl), + }, + { + name: 'company information', + config: companyInfoEnrichment, + inputs: { domain: 'stripe.com' }, + verify: (output) => { + assert.ok(output.employeeCount) + assert.ok(output.description) + }, + }, + { + name: 'company domain from name', + config: companyDomainEnrichment, + inputs: { companyName: 'Stripe' }, + verify: (output) => assert.equal(output.domain, 'stripe.com'), + }, +] + +const results: Array<{ + name: string + status: string + durationMs: number + creditsUsed?: number + costUsd?: number + error?: string +}> = [] +const transform = firecrawlEnrichTool.transformResponse +const pricing = firecrawlEnrichTool.hosting?.pricing +assert.ok(transform) +assert.equal(pricing?.type, 'custom') + +try { + for (const check of checks) { + const start = performance.now() + try { + const provider = check.config.providers[0] + assert.ok(provider) + const built = provider.buildParams(check.inputs) + assert.ok(built, 'Fixture did not provide usable identifiers') + const params = { ...built, apiKey } as FirecrawlEnrichParams + const request = firecrawlEnrichTool.request + assert.equal(typeof request.url, 'string') + assert.ok(request.body) + // boundary-raw-fetch: live test exercises the external provider using the production request configuration. + const response = await fetch(String(request.url), { + method: 'POST', + headers: request.headers?.(params), + body: JSON.stringify(request.body(params)), + signal: AbortSignal.timeout(180_000), + }) + const result = await transform(response, params) + if (check.expectNoMatch) { + assert.equal(result.success, false) + const failure = provider.projectFailure({ + error: result.error ?? 'Lookup failed', + output: result.output, + }) + assert.equal(failure.status, 'no_match') + assert.equal(result.output.creditsUsed, 0) + } else { + assert.ok(result.success, result.error) + const mapped = provider.mapOutput(result.output) + assert.ok(mapped, 'Expected a matching record') + assert.ok(check.verify) + check.verify(mapped) + } + assert.ok(Number.isFinite(result.output.creditsUsed)) + assert.equal(pricing?.type, 'custom') + const cost = pricing?.type === 'custom' ? pricing.getCost(params, result.output) : undefined + results.push({ + name: check.name, + status: 'passed', + durationMs: performance.now() - start, + creditsUsed: result.output.creditsUsed, + costUsd: typeof cost === 'number' ? cost : cost?.cost, + }) + logger.info('Live enrichment check passed', { + name: check.name, + creditsUsed: result.output.creditsUsed, + }) + } catch (error) { + results.push({ + name: check.name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error).replaceAll(apiKey, '[REDACTED]'), + }) + logger.error('Live enrichment check failed', { name: check.name }) + } + } +} finally { + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify({ checkedAt: new Date().toISOString(), results }, null, 2), + { mode: 0o600 } + ) +} + +if (results.some((result) => result.status !== 'passed')) process.exitCode = 1 diff --git a/apps/sim/scripts/test-landing-seo-e2e.ts b/apps/sim/scripts/test-landing-seo-e2e.ts index 70bccd4127b..57489ce5e65 100644 --- a/apps/sim/scripts/test-landing-seo-e2e.ts +++ b/apps/sim/scripts/test-landing-seo-e2e.ts @@ -422,6 +422,29 @@ async function run() { assert(String(product['@id']).endsWith('#software'), `Offers sit on ${product['@id']}`) }) + await check('pricing says self-hosting the open-source core is free', async () => { + const { body } = await get('/pricing') + const visibleText = decodeHtml( + body + .replace(//g, '') + .replace(//g, '') + .replace(/<[^>]+>/g, '') + ) + assert(visibleText.includes('open source under the Apache 2.0 license'), 'license note missing') + assert(visibleText.includes('self-host it for free'), 'self-hosting note missing') + assert(visibleText.includes('Self-Hosted Enterprise Features'), 'Enterprise row label missing') + assert(visibleText.includes('one credit is worth $0.005'), 'credit value note missing') + }) + + await check('Copilot Studio comparison aliases reach the comparison page', async () => { + for (const alias of ['microsoft-copilot-studio', 'copilot-studio']) { + const { status, location } = await get(`/comparisons/${alias}`) + assert.equal(status, 308, alias) + assert(location, `${alias}: expected a redirect`) + assert.equal(new URL(location, baseUrl).pathname, '/comparisons/microsoft-copilot', alias) + } + }) + await check('comparison pages lead with their answer and their tracked-prompt FAQs', async () => { const failures: string[] = [] for (const profile of [ diff --git a/apps/sim/serializer/gauge.test.ts b/apps/sim/serializer/gauge.test.ts new file mode 100644 index 00000000000..4955b6d0575 --- /dev/null +++ b/apps/sim/serializer/gauge.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getBlock } from '@/blocks' +import { GaugeBlock } from '@/blocks/blocks/gauge' +import { extractBlockParams } from '@/serializer/index' +import { prepareBlockState } from '@/stores/workflows/prepare-block-state' +import { gaugeListPromptsTool } from '@/tools/gauge/list_prompts' +import type { GaugeListPromptsParams } from '@/tools/gauge/types' +import { prepareToolRequest } from '@/tools/request-transport' + +describe('Gauge canvas operation changes', () => { + beforeEach(() => { + vi.mocked(getBlock).mockReturnValue(GaugeBlock) + }) + + it.each([false, true])( + 'does not reuse an old topic array after clearing the prompt filter (advanced: %s)', + (advancedMode) => { + const block = prepareBlockState({ + id: 'gauge_fixture', + type: 'gauge', + name: 'Gauge', + position: { x: 0, y: 0 }, + }) + block.advancedMode = advancedMode + block.subBlocks.operation.value = 'gauge_list_prompts' + block.subBlocks.manualOrg.value = 'example' + block.subBlocks.manualCategory.value = 'example' + block.subBlocks.topic.value = '["top_stale"]' + const params = extractBlockParams(block) + const mapped = GaugeBlock.tools.config?.params?.(params) + if (!mapped) throw new Error('Gauge parameter mapping is unavailable') + const request = prepareToolRequest(gaugeListPromptsTool, { + ...mapped, + org: 'example', + category: 'example', + accessToken: 'fixture-token', + } as GaugeListPromptsParams) + expect(new URL(request.url).searchParams.has('topic')).toBe(false) + } + ) +}) diff --git a/apps/sim/tools/errors.ts b/apps/sim/tools/errors.ts index 8a8809258a7..316d103f17e 100644 --- a/apps/sim/tools/errors.ts +++ b/apps/sim/tools/errors.ts @@ -17,6 +17,19 @@ export class HostedKeyRateLimitedError extends HttpError { } } +/** + * The tool's hosted key is reserved for paying payers and this run's payer is on the free plan. + * Carries a 402 so the run fails as the caller's plan, not as a Sim fault. + */ +export class HostedKeyRequiresPaidPlanError extends HttpError { + readonly statusCode = 402 + + constructor(message: string) { + super(message) + this.name = 'HostedKeyRequiresPaidPlanError' + } +} + /** No hosted keys are configured or available for this provider. */ export class HostedKeyUnavailableError extends HttpError { readonly statusCode = 503 diff --git a/apps/sim/tools/firecrawl/enrich.test.ts b/apps/sim/tools/firecrawl/enrich.test.ts new file mode 100644 index 00000000000..ab3f02c3ccc --- /dev/null +++ b/apps/sim/tools/firecrawl/enrich.test.ts @@ -0,0 +1,152 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { describe, expect, it } from 'vitest' +import { firecrawlEnrichTool } from '@/tools/firecrawl/enrich' + +async function transform(payload: unknown) { + if (!firecrawlEnrichTool.transformResponse) throw new Error('Missing response transform') + return firecrawlEnrichTool.transformResponse(jsonResponse(payload)) +} + +describe('Alexandria enrichment wire contract', () => { + it('surfaces provider errors inside a successful HTTP envelope', async () => { + const result = await transform({ + success: true, + data: { + creditsCost: 0, + alexandria: [ + { + error: { + code: 'THIRD_PARTY_DATA_TERMS_REQUIRED', + message: 'Accept provider terms', + status: 403, + }, + }, + ], + }, + }) + expect(result.success).toBe(false) + expect(result.error).toContain('Accept provider terms') + expect(result.output.statusCode).toBe(403) + }) + + it('preserves a provider 404 for the enrichment runner to classify as no match', async () => { + const result = await transform({ + success: true, + data: { creditsCost: 0, alexandria: [{ error: { message: 'No match', status: 404 } }] }, + }) + expect(result.success).toBe(false) + expect(result.output.statusCode).toBe(404) + }) + + it('rejects malformed success envelopes instead of reporting a clean miss', async () => { + await expect( + transform({ success: true, data: { creditsCost: 0, alexandria: [] } }) + ).rejects.toThrow(/result/i) + }) + + it('bills Firecrawl credits rather than the upstream provider metadata', async () => { + const result = await transform({ + success: true, + data: { + creditsCost: 10, + alexandria: [ + { + provider: 'fullenrich', + capability: 'companies/lookup', + data: { companies: [], metadata: { credits: 0 } }, + }, + ], + }, + }) + const pricing = firecrawlEnrichTool.hosting?.pricing + if (pricing?.type !== 'custom') throw new Error('Missing usage pricing') + expect( + pricing.getCost({ apiKey: 'test', enrichmentType: 'company' }, result.output) + ).toMatchObject({ cost: 0.01 }) + }) + + it.each([undefined, -1, '30', null])( + 'rejects unusable reported usage: %s', + async (creditsCost) => { + await expect( + transform({ + success: true, + data: { + creditsCost, + alexandria: [ + { provider: 'fullenrich', capability: 'contacts/work-email', data: { results: [] } }, + ], + }, + }) + ).rejects.toThrow(/credits/i) + } + ) + + it.each([ + ['DELIVERABLE', true], + ['CATCH_ALL', false], + ['HIGH_PROBABILITY', false], + ])('does not claim mailbox verification for %s', async (status, verified) => { + const result = await transform({ + success: true, + data: { + creditsCost: 30, + alexandria: [ + { + provider: 'fullenrich', + capability: 'contacts/work-email', + data: { + results: [ + { + contact_info: { + most_probable_work_email: { email: 'person@example.com', status }, + }, + }, + ], + }, + }, + ], + }, + }) + expect(result.output.email).toBe('person@example.com') + expect(result.output.emailVerified).toBe(verified) + }) + + it('keeps empty, free contact results as a successful no-match', async () => { + const result = await transform({ + success: true, + data: { + creditsCost: 0, + alexandria: [ + { provider: 'fullenrich', capability: 'contacts/work-email', data: { results: [] } }, + ], + }, + }) + expect(result.success).toBe(true) + expect(result.output.email).toBeNull() + expect(result.output.creditsUsed).toBe(0) + }) + + it('maps PDL contact records without claiming mailbox verification', async () => { + const result = await transform({ + success: true, + data: { + creditsCost: 60, + alexandria: [ + { + provider: 'people-data-labs', + capability: 'person/enrich', + data: { + data: { work_email: 'person@example.com', mobile_phone: '+12025550123' }, + }, + }, + ], + }, + }) + expect(result.output).toMatchObject({ + email: 'person@example.com', + emailVerified: false, + phone: '+12025550123', + }) + }) +}) diff --git a/apps/sim/tools/firecrawl/enrich.ts b/apps/sim/tools/firecrawl/enrich.ts new file mode 100644 index 00000000000..5960606b308 --- /dev/null +++ b/apps/sim/tools/firecrawl/enrich.ts @@ -0,0 +1,255 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { filterUndefined, isRecordLike, toArray, toRecord } from '@sim/utils/object' +import { normalizeDomain, splitName } from '@/enrichments/providers' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' +import type { FirecrawlEnrichParams, FirecrawlEnrichResponse } from '@/tools/firecrawl/types' +import type { ToolConfig } from '@/tools/types' + +function enrichmentCall(params: FirecrawlEnrichParams) { + const fullName = params.fullName?.trim() + const companyDomain = normalizeDomain(params.companyDomain) + const companyName = params.companyName?.trim() + const linkedinUrl = params.linkedinUrl?.trim() + const email = params.email?.trim() + + switch (params.enrichmentType) { + case 'work_email': + case 'phone': { + const name = splitName(fullName) + const company = companyDomain || companyName + if (!linkedinUrl && !(fullName && company)) { + throw new Error('Provide a LinkedIn URL or a full name and company for contact enrichment') + } + if (!linkedinUrl && !name) { + const fields = + params.enrichmentType === 'phone' ? ['mobile_phone', 'phone_numbers'] : ['work_email'] + return { + provider: 'people-data-labs', + capability: 'person/enrich', + options: { + name: fullName, + company, + min_likelihood: 6, + required: fields.join(' OR '), + data_include: fields.join(','), + }, + } + } + return { + provider: 'fullenrich', + capability: params.enrichmentType === 'phone' ? 'contacts/mobile' : 'contacts/work-email', + options: filterUndefined({ + linkedin_url: linkedinUrl || undefined, + first_name: name?.firstName, + last_name: name?.lastName, + domain: companyDomain || undefined, + company_name: companyName || undefined, + }), + } + } + case 'linkedin': + if (email) { + return { provider: 'fullenrich', capability: 'contacts/reverse-email', options: { email } } + } + if (!fullName || !(companyDomain || companyName)) { + throw new Error('Provide an email or a full name and company for LinkedIn enrichment') + } + return { + provider: 'people-data-labs', + capability: 'person/enrich', + options: { + name: fullName, + company: companyDomain || companyName, + min_likelihood: 6, + required: 'linkedin_url', + data_include: 'linkedin_url', + }, + } + case 'company': + if (!companyDomain) throw new Error('Provide a company domain for company enrichment') + return { + provider: 'people-data-labs', + capability: 'company/enrich', + options: { + website: companyDomain, + min_likelihood: 6, + required: 'employee_count OR summary', + data_include: 'employee_count,size,summary,website', + }, + } + case 'company_domain': + if (!companyName) throw new Error('Provide a company name to find its domain') + return { + provider: 'people-data-labs', + capability: 'company/enrich', + options: { + name: companyName, + required: 'website', + data_include: 'website', + }, + } + default: + throw new Error('Unsupported Firecrawl enrichment type') + } +} + +/** Single-record Alexandria lookups, billed from Firecrawl's reported creditsCost. */ +export const firecrawlEnrichTool: ToolConfig = { + id: 'firecrawl_enrich', + name: 'Firecrawl Enrich', + description: + 'Find work emails, phone numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company, name-based profile, and single-part-name contact lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit.', + version: '1.0.0', + hosting: firecrawlHosting(), + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Firecrawl API key', + }, + enrichmentType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Lookup type: work_email, phone, linkedin, company, or company_domain', + }, + fullName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Full name of the person', + }, + companyDomain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website domain', + }, + companyName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name when the domain is unknown', + }, + linkedinUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL for work email or mobile lookup', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email address for reverse LinkedIn profile lookup', + }, + }, + request: { + url: 'https://api.firecrawl.dev/v2/scrape', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => ({ alexandria: enrichmentCall(params) }), + }, + transformResponse: async (response) => { + const envelope = toRecord(await response.json()) + const data = toRecord(envelope.data) + const results = toArray(data.alexandria) + const result = toRecord(results[0]) + const error = toRecord(result.error) + const output: FirecrawlEnrichResponse['output'] = { + email: null, + emailVerified: false, + phone: null, + linkedinUrl: null, + domain: null, + employeeCount: null, + description: null, + creditsUsed: 0, + statusCode: typeof error.status === 'number' ? error.status : response.status, + } + if (!response.ok || envelope.success !== true || result.error) { + return { + success: false, + error: + toStringOrNull(error.message) ?? + toStringOrNull(envelope.error) ?? + `Firecrawl enrichment failed (${output.statusCode})`, + output, + } + } + if (results.length !== 1 || !isRecordLike(result.data)) { + throw new Error('Firecrawl enrichment response missing its single provider result') + } + if ( + typeof data.creditsCost !== 'number' || + !Number.isFinite(data.creditsCost) || + data.creditsCost < 0 + ) { + throw new Error('Firecrawl enrichment response missing valid creditsCost') + } + output.creditsUsed = data.creditsCost + const record = result.data + if (result.provider === 'fullenrich') { + const contact = toRecord(toArray(record.results)[0]) + const contactInfo = toRecord(contact.contact_info) + const workEmail = toRecord(contactInfo.most_probable_work_email) + const phone = toRecord(contactInfo.most_probable_phone) + const profile = toRecord(contact.profile) + const social = toRecord(toRecord(profile.social_profiles).professional_network) + output.email = toStringOrNull(workEmail.email) + output.emailVerified = workEmail.status === 'DELIVERABLE' + output.phone = toStringOrNull(phone.number) + output.linkedinUrl = toStringOrNull(social.url) + } else if (result.provider === 'people-data-labs') { + const person = toRecord(record.data) + output.email = toStringOrNull(person.work_email) + output.phone = + toStringOrNull(person.mobile_phone) || + toArray(person.phone_numbers) + .map(toStringOrNull) + .find((phone) => phone?.trim()) || + null + output.linkedinUrl = toStringOrNull(person.linkedin_url) + output.domain = toStringOrNull(record.website) + output.employeeCount = + typeof record.employee_count === 'number' + ? String(record.employee_count) + : toStringOrNull(record.size) + output.description = toStringOrNull(record.summary) + } else { + throw new Error('Firecrawl enrichment returned an unexpected provider result') + } + return { success: true, output } + }, + outputs: { + email: { type: 'string', description: 'Work email, when found', optional: true }, + emailVerified: { + type: 'boolean', + description: + 'True only when FullEnrich reports DELIVERABLE; catch-all and high-probability addresses remain unverified', + }, + phone: { + type: 'string', + description: 'Phone number, preferring mobile when available', + optional: true, + }, + linkedinUrl: { + type: 'string', + description: 'LinkedIn profile URL, when found', + optional: true, + }, + domain: { type: 'string', description: 'Company website domain, when found', optional: true }, + employeeCount: { + type: 'string', + description: 'Company headcount or size range', + optional: true, + }, + description: { type: 'string', description: 'Company description', optional: true }, + creditsUsed: { type: 'number', description: 'Firecrawl credits charged for this lookup' }, + statusCode: { type: 'number', description: 'Provider or HTTP response status code' }, + }, +} diff --git a/apps/sim/tools/firecrawl/index.ts b/apps/sim/tools/firecrawl/index.ts index 4c1700044c7..d9b7a3a7972 100644 --- a/apps/sim/tools/firecrawl/index.ts +++ b/apps/sim/tools/firecrawl/index.ts @@ -25,3 +25,5 @@ export const firecrawlBatchScrapeTool = batchScrapeTool export const firecrawlBatchScrapeStatusTool = batchScrapeStatusTool export const firecrawlExtractStatusTool = extractStatusTool export const firecrawlCreditUsageTool = creditUsageTool + +export { firecrawlEnrichTool } from './enrich' diff --git a/apps/sim/tools/firecrawl/types.ts b/apps/sim/tools/firecrawl/types.ts index cf2aad3d780..60dd60c3ef7 100644 --- a/apps/sim/tools/firecrawl/types.ts +++ b/apps/sim/tools/firecrawl/types.ts @@ -1,5 +1,29 @@ import type { OutputProperty, ToolResponse } from '@/tools/types' +export interface FirecrawlEnrichParams { + apiKey: string + enrichmentType: 'work_email' | 'phone' | 'linkedin' | 'company' | 'company_domain' + fullName?: string + companyDomain?: string + companyName?: string + linkedinUrl?: string + email?: string +} + +export interface FirecrawlEnrichResponse extends ToolResponse { + output: { + email: string | null + emailVerified: boolean + phone: string | null + linkedinUrl: string | null + domain: string | null + employeeCount: string | null + description: string | null + creditsUsed: number + statusCode: number + } +} + /** * Shared output property definitions for Firecrawl API responses. * Based on Firecrawl API documentation: https://docs.firecrawl.dev/api-reference diff --git a/apps/sim/tools/flow/add-link.ts b/apps/sim/tools/flow/add-link.ts new file mode 100644 index 00000000000..64f125e4026 --- /dev/null +++ b/apps/sim/tools/flow/add-link.ts @@ -0,0 +1,71 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowAddLinkParams, FlowAddLinkResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowAddLinkTool: ToolConfig = { + id: 'flow_add_link', + name: 'Flow Add Link', + description: 'Add a downstream relationship between Flow entities on the selected branch.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID returned by Search Entities or List Entities', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + destinationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the downstream entity to link or unlink', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/links` + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + destinationId: params.destinationId.trim(), + branchId: params.branchId?.trim() || undefined, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity + return { success: true, output: { entity: mapFlowEntity(data) } } + }, + outputs: { + entity: { type: 'json', properties: FLOW_ENTITY_PROPERTIES, description: 'Add Link result' }, + }, +} diff --git a/apps/sim/tools/flow/create-branch.ts b/apps/sim/tools/flow/create-branch.ts new file mode 100644 index 00000000000..7a0c5ac9119 --- /dev/null +++ b/apps/sim/tools/flow/create-branch.ts @@ -0,0 +1,75 @@ +import { flowHeaders } from '@/lib/flow' +import type { FlowCreateBranchParams, FlowCreateBranchResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowCreateBranchTool: ToolConfig = { + id: 'flow_create_branch', + name: 'Flow Create Branch', + description: + 'Create a working branch before modifying requirements, tests, or other project entities.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the new branch', + }, + parentId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Parent branch ID; omit for Base', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the new branch', + }, + isVariant: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create a variant branch', + }, + }, + request: { + url: (params) => + flowUrl(params, `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/branch`), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + name: params.branchName, + parent_id: params.parentId?.trim() || undefined, + description: params.description, + is_variant: params.isVariant, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as { id: string; number: number } + return { success: true, output: { id: data.id, number: data.number } } + }, + outputs: { + id: { type: 'string', description: 'Created branch ID' }, + number: { type: 'number', description: 'Assigned branch number' }, + }, +} diff --git a/apps/sim/tools/flow/create-comment.ts b/apps/sim/tools/flow/create-comment.ts new file mode 100644 index 00000000000..ce8e0a295d1 --- /dev/null +++ b/apps/sim/tools/flow/create-comment.ts @@ -0,0 +1,74 @@ +import type { FlowComment } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowCreateCommentParams, FlowCreateCommentResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_COMMENT_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowComment, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowCreateCommentTool: ToolConfig = + { + id: 'flow_create_comment', + name: 'Flow Create Comment', + description: + 'Start a thread on an entity or reply using the original comment ID as entityId. Set the branch explicitly; replies do not inherit it.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID, or the original comment ID when reading or posting thread replies', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Plain-text comment body', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/comments` + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ text: params.text, branch_id: params.branchId?.trim() || undefined }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowComment + return { success: true, output: { comment: mapFlowComment(data) } } + }, + outputs: { + comment: { + type: 'json', + properties: FLOW_COMMENT_PROPERTIES, + description: 'Create Comment result', + }, + }, + } diff --git a/apps/sim/tools/flow/create-entities.ts b/apps/sim/tools/flow/create-entities.ts new file mode 100644 index 00000000000..c7a1d7b65fc --- /dev/null +++ b/apps/sim/tools/flow/create-entities.ts @@ -0,0 +1,75 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowCreateEntitiesParams, FlowCreateEntitiesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowEntityBatch, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowCreateEntitiesTool: ToolConfig< + FlowCreateEntitiesParams, + FlowCreateEntitiesResponse +> = { + id: 'flow_create_entities', + name: 'Flow Create Entities', + description: + 'Create 1\u2013100 entities atomically on a single branch. A timed-out request may already have committed; inspect the branch before retrying.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + entities: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of 1\u2013100 entities: [{entityType, name, id?}]. IDs may be omitted for server generation', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/batch` + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + entities: flowEntityBatch(params.entities), + branchId: params.branchId?.trim() || undefined, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity[] + return { success: true, output: { entities: data.map(mapFlowEntity) } } + }, + outputs: { + entities: { + type: 'array', + items: { type: 'object', properties: FLOW_ENTITY_PROPERTIES }, + description: 'Create Entities result', + }, + }, +} diff --git a/apps/sim/tools/flow/create-entity.ts b/apps/sim/tools/flow/create-entity.ts new file mode 100644 index 00000000000..4684bc79d95 --- /dev/null +++ b/apps/sim/tools/flow/create-entity.ts @@ -0,0 +1,69 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowCreateEntityParams, FlowCreateEntityResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowCreateEntityTool: ToolConfig = { + id: 'flow_create_entity', + name: 'Flow Create Entity', + description: + 'Create an entity in a category on the selected branch. Branch protection and the connected user permissions remain enforced.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + entityType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID returned by Get Data Model', + }, + name: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Entity name' }, + }, + request: { + url: (params) => + flowUrl(params, `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities`), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + entityType: params.entityType.trim(), + name: params.name, + branchId: params.branchId?.trim() || undefined, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity + return { success: true, output: { entity: mapFlowEntity(data) } } + }, + outputs: { + entity: { + type: 'json', + properties: FLOW_ENTITY_PROPERTIES, + description: 'Create Entity result', + }, + }, +} diff --git a/apps/sim/tools/flow/delete-comment.ts b/apps/sim/tools/flow/delete-comment.ts new file mode 100644 index 00000000000..d1c7628101d --- /dev/null +++ b/apps/sim/tools/flow/delete-comment.ts @@ -0,0 +1,56 @@ +import { flowHeaders } from '@/lib/flow' +import type { FlowDeleteCommentParams, FlowDeleteCommentResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowDeleteCommentTool: ToolConfig = + { + id: 'flow_delete_comment', + name: 'Flow Delete Comment', + description: 'Delete a comment authored by the connected user.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID, or the original comment ID when reading or posting thread replies', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Comment ID; entityId must be the comment's returned entity_id", + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/comments/${safeUrlPathSegment(params.commentId, 'commentId')}` + ), + method: 'DELETE', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + return { success: true, output: {} } + }, + outputs: {}, + } diff --git a/apps/sim/tools/flow/delete-entity.ts b/apps/sim/tools/flow/delete-entity.ts new file mode 100644 index 00000000000..775c84da63d --- /dev/null +++ b/apps/sim/tools/flow/delete-entity.ts @@ -0,0 +1,56 @@ +import { flowHeaders } from '@/lib/flow' +import type { FlowDeleteEntityParams, FlowDeleteEntityResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowDeleteEntityTool: ToolConfig = { + id: 'flow_delete_entity', + name: 'Flow Delete Entity', + description: 'Delete an entity on the selected branch. Branch protection is enforced.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID returned by Search Entities or List Entities', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}`, + { branch_id: params.branchId?.trim() || undefined } + ), + method: 'DELETE', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + return { success: true, output: {} } + }, + outputs: {}, +} diff --git a/apps/sim/tools/flow/filter-entities.ts b/apps/sim/tools/flow/filter-entities.ts new file mode 100644 index 00000000000..999bc9ae09c --- /dev/null +++ b/apps/sim/tools/flow/filter-entities.ts @@ -0,0 +1,102 @@ +import { flowHeaders } from '@/lib/flow' +import type { FlowFilterEntitiesParams, FlowFilterEntitiesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowFilter, + flowLimit, + flowUrl, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowFilterEntitiesTool: ToolConfig< + FlowFilterEntitiesParams, + FlowFilterEntitiesResponse +> = { + id: 'flow_filter_entities', + name: 'Flow Filter Entities', + description: + 'Read one page of IDs matching typed field conditions and AND/OR groups. Continue until nextCursor is null, including after empty pages; results reflect current state.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + filter: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Filter object: {kind:"type",operation:"overlaps",values:[categoryId]}, {kind:"field",category_id,field_key,operation:"contains",value}, or {kind:"group",operator:"and"|"or",conditions:[...]}. Use data-model IDs and field-compatible operators; at most 50 nodes and 5 group levels', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque continuation cursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results in this page', + }, + timezone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'IANA timezone for date filters; defaults to UTC', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/filter/query` + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + filter: flowFilter(params.filter), + branch_id: params.branchId?.trim() || undefined, + cursor: params.cursor?.trim() || undefined, + limit: flowLimit(params.limit, 100, 50), + timezone: params.timezone?.trim() || undefined, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as { entity_ids: string[]; next_cursor: string | null } + return { + success: true, + output: { entityIds: data.entity_ids, nextCursor: data.next_cursor ?? null }, + } + }, + outputs: { + entityIds: { + type: 'array', + items: { type: 'string' }, + description: 'Matching entity IDs; fetch entities to read values', + }, + nextCursor: { + type: 'string', + nullable: true, + description: 'Continue until null, even after an empty page', + }, + }, +} diff --git a/apps/sim/tools/flow/get-comment.ts b/apps/sim/tools/flow/get-comment.ts new file mode 100644 index 00000000000..f5268fdcc69 --- /dev/null +++ b/apps/sim/tools/flow/get-comment.ts @@ -0,0 +1,72 @@ +import type { FlowComment } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowGetCommentParams, FlowGetCommentResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_COMMENT_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowComment, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowGetCommentTool: ToolConfig = { + id: 'flow_get_comment', + name: 'Flow Get Comment', + description: 'Read a comment using its ID and its returned entity_id as the parent path.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID, or the original comment ID when reading or posting thread replies', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Comment ID; entityId must be the comment's returned entity_id", + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/comments/${safeUrlPathSegment(params.commentId, 'commentId')}` + ), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowComment + return { success: true, output: { comment: mapFlowComment(data) } } + }, + outputs: { + comment: { + type: 'json', + properties: FLOW_COMMENT_PROPERTIES, + description: 'Get Comment result', + }, + }, +} diff --git a/apps/sim/tools/flow/get-data-model.ts b/apps/sim/tools/flow/get-data-model.ts new file mode 100644 index 00000000000..e2d0d62e553 --- /dev/null +++ b/apps/sim/tools/flow/get-data-model.ts @@ -0,0 +1,58 @@ +import type { FlowCategory } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowGetDataModelParams, FlowGetDataModelResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_CATEGORY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowCategory, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowGetDataModelTool: ToolConfig = { + id: 'flow_get_data_model', + name: 'Flow Get Data Model', + description: + 'Read the project categories and field definitions before interpreting values or writing custom fields.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + }, + request: { + url: (params) => + flowUrl(params, `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/data-model`), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowCategory[] + return { success: true, output: { categories: data.map(mapFlowCategory) } } + }, + outputs: { + categories: { + type: 'array', + items: { type: 'object', properties: FLOW_CATEGORY_PROPERTIES }, + description: 'Get Data Model result', + }, + }, +} diff --git a/apps/sim/tools/flow/get-entities.ts b/apps/sim/tools/flow/get-entities.ts new file mode 100644 index 00000000000..baebfa3d6a1 --- /dev/null +++ b/apps/sim/tools/flow/get-entities.ts @@ -0,0 +1,79 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowGetEntitiesParams, FlowGetEntitiesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowIds, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowGetEntitiesTool: ToolConfig = { + id: 'flow_get_entities', + name: 'Flow Get Entities', + description: + 'Read the full values, relationships, and timestamps of up to 100 Flow entities in one project and branch.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + entityIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated entity IDs; up to 100 per read', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${flowIds( + params.entityIds + ) + .map((id) => safeUrlPathSegment(id, 'entityIds')) + .join(',')}`, + { branch_id: params.branchId?.trim() || undefined, include_metadata: true } + ), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity[] + return { success: true, output: { entities: data.map(mapFlowEntity) } } + }, + outputs: { + entities: { + type: 'array', + items: { type: 'object', properties: FLOW_ENTITY_PROPERTIES }, + description: 'Get Entities result', + }, + }, +} diff --git a/apps/sim/tools/flow/get-entity-history.ts b/apps/sim/tools/flow/get-entity-history.ts new file mode 100644 index 00000000000..78eb3571eec --- /dev/null +++ b/apps/sim/tools/flow/get-entity-history.ts @@ -0,0 +1,96 @@ +import type { FlowHistoryPage } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowGetEntityHistoryParams, FlowGetEntityHistoryResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_HISTORY_ENTRY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowIds, + flowLimit, + flowUrl, + mapFlowHistoryEntry, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowGetEntityHistoryTool: ToolConfig< + FlowGetEntityHistoryParams, + FlowGetEntityHistoryResponse +> = { + id: 'flow_get_entity_history', + name: 'Flow Get Entity History', + description: + 'Read a page of change history for up to 500 entities. Keep project, branch, and entity IDs fixed while paging; restart if a rebase expires the cursor.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + entityIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated entity IDs; up to 500 per history page', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque continuation cursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results in this page', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/history` + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + entity_ids: flowIds(params.entityIds, 500), + branch_id: params.branchId?.trim() || undefined, + cursor: params.cursor?.trim() || undefined, + limit: flowLimit(params.limit, 100, 100), + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowHistoryPage + return { + success: true, + output: { + entries: data.items.map(mapFlowHistoryEntry), + nextCursor: data.next_cursor ?? null, + }, + } + }, + outputs: { + entries: { + type: 'array', + items: { type: 'object', properties: FLOW_HISTORY_ENTRY_PROPERTIES }, + description: 'Entity change history page', + }, + nextCursor: { type: 'string', nullable: true, description: 'Continue until null' }, + }, +} diff --git a/apps/sim/tools/flow/get-project.ts b/apps/sim/tools/flow/get-project.ts new file mode 100644 index 00000000000..2b2e1e6bba4 --- /dev/null +++ b/apps/sim/tools/flow/get-project.ts @@ -0,0 +1,57 @@ +import type { FlowProject } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowGetProjectParams, FlowGetProjectResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_PROJECT_PROPERTIES, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowProject, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowGetProjectTool: ToolConfig = { + id: 'flow_get_project', + name: 'Flow Get Project', + description: 'Read a Flow project by its UUID.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + }, + request: { + url: (params) => + flowUrl(params, `/project/${safeUrlPathSegment(params.projectId, 'projectId')}`), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowProject + return { success: true, output: { project: mapFlowProject(data) } } + }, + outputs: { + project: { + type: 'json', + properties: FLOW_PROJECT_PROPERTIES, + description: 'Get Project result', + }, + }, +} diff --git a/apps/sim/tools/flow/index.ts b/apps/sim/tools/flow/index.ts new file mode 100644 index 00000000000..ec861d9d8e5 --- /dev/null +++ b/apps/sim/tools/flow/index.ts @@ -0,0 +1,24 @@ +export { flowAddLinkTool } from './add-link' +export { flowCreateBranchTool } from './create-branch' +export { flowCreateCommentTool } from './create-comment' +export { flowCreateEntitiesTool } from './create-entities' +export { flowCreateEntityTool } from './create-entity' +export { flowDeleteCommentTool } from './delete-comment' +export { flowDeleteEntityTool } from './delete-entity' +export { flowFilterEntitiesTool } from './filter-entities' +export { flowGetCommentTool } from './get-comment' +export { flowGetDataModelTool } from './get-data-model' +export { flowGetEntitiesTool } from './get-entities' +export { flowGetEntityHistoryTool } from './get-entity-history' +export { flowGetProjectTool } from './get-project' +export { flowListBranchesTool } from './list-branches' +export { flowListCommentsTool } from './list-comments' +export { flowListEntitiesTool } from './list-entities' +export { flowListProjectsTool } from './list-projects' +export { flowRemoveLinkTool } from './remove-link' +export { flowRestoreEntityTool } from './restore-entity' +export { flowSearchEntitiesTool } from './search-entities' +export { flowSetValueTool } from './set-value' +export { flowSetValuesTool } from './set-values' +export type * from './types' +export { flowUpdateCommentTool } from './update-comment' diff --git a/apps/sim/tools/flow/list-branches.ts b/apps/sim/tools/flow/list-branches.ts new file mode 100644 index 00000000000..8c793edc267 --- /dev/null +++ b/apps/sim/tools/flow/list-branches.ts @@ -0,0 +1,57 @@ +import type { FlowBranch } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowListBranchesParams, FlowListBranchesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_BRANCH_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowBranch, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowListBranchesTool: ToolConfig = { + id: 'flow_list_branches', + name: 'Flow List Branches', + description: 'List the Flow project branches and their review and protection state.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + }, + request: { + url: (params) => + flowUrl(params, `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/branch`), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowBranch[] + return { success: true, output: { branches: data.map(mapFlowBranch) } } + }, + outputs: { + branches: { + type: 'array', + items: { type: 'object', properties: FLOW_BRANCH_PROPERTIES }, + description: 'List Branches result', + }, + }, +} diff --git a/apps/sim/tools/flow/list-comments.ts b/apps/sim/tools/flow/list-comments.ts new file mode 100644 index 00000000000..a4047602123 --- /dev/null +++ b/apps/sim/tools/flow/list-comments.ts @@ -0,0 +1,83 @@ +import type { FlowComment } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowListCommentsParams, FlowListCommentsResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_COMMENT_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowComment, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowListCommentsTool: ToolConfig = { + id: 'flow_list_comments', + name: 'Flow List Comments', + description: + 'Read direct comment threads for an entity or direct replies to the original comment ID. Keep the same branch when following replies.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID, or the original comment ID when reading or posting thread replies', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + includeResolved: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include resolved comments', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/comments`, + { + branch_id: params.branchId?.trim() || undefined, + include_resolved: params.includeResolved, + } + ), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowComment[] + return { success: true, output: { comments: data.map(mapFlowComment) } } + }, + outputs: { + comments: { + type: 'array', + items: { type: 'object', properties: FLOW_COMMENT_PROPERTIES }, + description: 'List Comments result', + }, + }, +} diff --git a/apps/sim/tools/flow/list-entities.ts b/apps/sim/tools/flow/list-entities.ts new file mode 100644 index 00000000000..100adc72aaf --- /dev/null +++ b/apps/sim/tools/flow/list-entities.ts @@ -0,0 +1,103 @@ +import type { FlowEntityPage } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowListEntitiesParams, FlowListEntitiesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowLimit, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowListEntitiesTool: ToolConfig = { + id: 'flow_list_entities', + name: 'Flow List Entities', + description: + 'Read one page of live Flow entities. Continue with nextPageStart while moreAvailable is true, including after an empty page.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + entityType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict to this category ID from Get Data Model', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque continuation cursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results in this page', + }, + }, + request: { + url: (params) => + flowUrl(params, `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities`, { + branch_id: params.branchId?.trim() || undefined, + entity_type: params.entityType?.trim() || undefined, + starting_after: params.cursor?.trim() || '0', + limit: flowLimit(params.limit, 1000, 50), + }), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntityPage + if (data.moreAvailable && !data.nextPageStart) + throw new Error('Flow returned an incomplete page without a continuation cursor') + return { + success: true, + output: { + entities: data.items.map(mapFlowEntity), + nextPageStart: data.nextPageStart ?? null, + moreAvailable: data.moreAvailable ?? false, + }, + } + }, + outputs: { + entities: { + type: 'array', + items: { type: 'object', properties: FLOW_ENTITY_PROPERTIES }, + description: 'Entity states on this page', + }, + nextPageStart: { + type: 'string', + nullable: true, + description: 'Opaque cursor for the next page', + }, + moreAvailable: { type: 'boolean', description: 'Continue even after empty pages while true' }, + }, +} diff --git a/apps/sim/tools/flow/list-projects.ts b/apps/sim/tools/flow/list-projects.ts new file mode 100644 index 00000000000..cf331e2b628 --- /dev/null +++ b/apps/sim/tools/flow/list-projects.ts @@ -0,0 +1,57 @@ +import type { FlowProject } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowListProjectsParams, FlowListProjectsResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_PROJECT_PROPERTIES, + FLOW_REDIRECT_POLICY, + flowLimit, + flowUrl, + mapFlowProject, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' + +export const flowListProjectsTool: ToolConfig = { + id: 'flow_list_projects', + name: 'Flow List Projects', + description: + 'List projects accessible to the connected Flow user. The API returns up to the requested limit without a continuation cursor.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results in this page', + }, + }, + request: { + url: (params) => flowUrl(params, `/project`, { limit: flowLimit(params.limit, 1000, 100) }), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowProject[] + return { success: true, output: { projects: data.map(mapFlowProject) } } + }, + outputs: { + projects: { + type: 'array', + items: { type: 'object', properties: FLOW_PROJECT_PROPERTIES }, + description: 'List Projects result', + }, + }, +} diff --git a/apps/sim/tools/flow/remove-link.ts b/apps/sim/tools/flow/remove-link.ts new file mode 100644 index 00000000000..bec6ec1565b --- /dev/null +++ b/apps/sim/tools/flow/remove-link.ts @@ -0,0 +1,71 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowRemoveLinkParams, FlowRemoveLinkResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowRemoveLinkTool: ToolConfig = { + id: 'flow_remove_link', + name: 'Flow Remove Link', + description: 'Remove a downstream relationship between Flow entities on the selected branch.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID returned by Search Entities or List Entities', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + destinationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the downstream entity to link or unlink', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/links/remove` + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + destinationId: params.destinationId.trim(), + branchId: params.branchId?.trim() || undefined, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity + return { success: true, output: { entity: mapFlowEntity(data) } } + }, + outputs: { + entity: { type: 'json', properties: FLOW_ENTITY_PROPERTIES, description: 'Remove Link result' }, + }, +} diff --git a/apps/sim/tools/flow/restore-entity.ts b/apps/sim/tools/flow/restore-entity.ts new file mode 100644 index 00000000000..b333166fd82 --- /dev/null +++ b/apps/sim/tools/flow/restore-entity.ts @@ -0,0 +1,58 @@ +import { flowHeaders } from '@/lib/flow' +import type { FlowRestoreEntityParams, FlowRestoreEntityResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowRestoreEntityTool: ToolConfig = + { + id: 'flow_restore_entity', + name: 'Flow Restore Entity', + description: + 'Restore a deleted entity with its prior fields and links. Restoring a child does not restore its deleted parent.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID returned by Search Entities or List Entities', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/restore`, + { branch_id: params.branchId?.trim() || undefined } + ), + method: 'POST', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + return { success: true, output: {} } + }, + outputs: {}, + } diff --git a/apps/sim/tools/flow/search-entities.ts b/apps/sim/tools/flow/search-entities.ts new file mode 100644 index 00000000000..e8ea88e20b1 --- /dev/null +++ b/apps/sim/tools/flow/search-entities.ts @@ -0,0 +1,97 @@ +import type { FlowSearchResult } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowSearchEntitiesParams, FlowSearchEntitiesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + FLOW_SEARCH_RESULT_PROPERTIES, + flowLimit, + flowSearchQuery, + flowUrl, + mapFlowSearchResult, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowSearchEntitiesTool: ToolConfig< + FlowSearchEntitiesParams, + FlowSearchEntitiesResponse +> = { + id: 'flow_search_entities', + name: 'Flow Search Entities', + description: + 'Search live Flow entities by name, supported field values, or exact Flow/Human ID. Returns at most 100 compact matches; fetch entities for full values. Text results may be unavailable while the provider index warms.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + entityType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict to this category ID from Get Data Model', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Search names and supported field values, or an exact Flow/Human ID; at most 1024 characters', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results in this page', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/search`, + { + query: flowSearchQuery(params.query), + branch_id: params.branchId?.trim() || undefined, + entity_type: params.entityType?.trim() || undefined, + limit: flowLimit(params.limit, 100, 50), + } + ), + method: 'GET', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + retry: { + enabled: true, + maxRetries: 2, + initialDelayMs: 1000, + maxDelayMs: 10000, + retryIdempotentOnly: true, + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowSearchResult[] + return { success: true, output: { matches: data.map(mapFlowSearchResult) } } + }, + outputs: { + matches: { + type: 'array', + items: { type: 'object', properties: FLOW_SEARCH_RESULT_PROPERTIES }, + description: 'Search Entities result', + }, + }, +} diff --git a/apps/sim/tools/flow/set-value.ts b/apps/sim/tools/flow/set-value.ts new file mode 100644 index 00000000000..f516c405f3a --- /dev/null +++ b/apps/sim/tools/flow/set-value.ts @@ -0,0 +1,91 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowSetValueParams, FlowSetValueResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowSetValueTool: ToolConfig = { + id: 'flow_set_value', + name: 'Flow Set Value', + description: + 'Set one field on a Flow entity using a key from the data model and a JSON value of the documented field type.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID returned by Search Entities or List Entities', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + fieldKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Stored field key returned by Get Data Model', + }, + value: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON-encoded field value matching the data model. Use "text" for a string, 123 for a number, true or false for a boolean, an array or object, or null to clear the field. Use "" for an empty string.', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/values` + ), + method: 'PUT', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => { + const key = params.fieldKey.trim() + if (!key) throw new Error('Field key is required') + if (typeof params.value !== 'string') throw new Error('Value must be JSON text') + let value: unknown + try { + value = JSON.parse(params.value) + } catch { + throw new Error('Value must be valid JSON; wrap text values in double quotes') + } + return { + key, + value, + branchId: params.branchId?.trim() || undefined, + } + }, + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity + return { success: true, output: { entity: mapFlowEntity(data) } } + }, + outputs: { + entity: { type: 'json', properties: FLOW_ENTITY_PROPERTIES, description: 'Set Value result' }, + }, +} diff --git a/apps/sim/tools/flow/set-values.ts b/apps/sim/tools/flow/set-values.ts new file mode 100644 index 00000000000..9d3e53c141b --- /dev/null +++ b/apps/sim/tools/flow/set-values.ts @@ -0,0 +1,71 @@ +import type { FlowEntity } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowSetValuesParams, FlowSetValuesResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_ENTITY_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowUrl, + flowValueBatch, + mapFlowEntity, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowSetValuesTool: ToolConfig = { + id: 'flow_set_values', + name: 'Flow Set Values', + description: + 'Set 1\u2013100 field values atomically on one branch. Returns each affected entity once in first-touched order.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + branchId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Branch ID; omit or use master for Base', + }, + updates: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Array of 1\u2013100 field updates: [{entityId, key, value}]', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/values/batch` + ), + method: 'PUT', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => ({ + updates: flowValueBatch(params.updates), + branchId: params.branchId?.trim() || undefined, + }), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowEntity[] + return { success: true, output: { entities: data.map(mapFlowEntity) } } + }, + outputs: { + entities: { + type: 'array', + items: { type: 'object', properties: FLOW_ENTITY_PROPERTIES }, + description: 'Set Values result', + }, + }, +} diff --git a/apps/sim/tools/flow/types.ts b/apps/sim/tools/flow/types.ts new file mode 100644 index 00000000000..0d10908741f --- /dev/null +++ b/apps/sim/tools/flow/types.ts @@ -0,0 +1,262 @@ +import type { + mapFlowBranch, + mapFlowCategory, + mapFlowComment, + mapFlowEntity, + mapFlowHistoryEntry, + mapFlowProject, + mapFlowSearchResult, +} from '@/tools/flow/utils' +import type { ToolResponse } from '@/tools/types' + +export interface FlowAuthParams { + accessToken: string + domain: string + providerWorkspaceId: string +} + +export interface FlowListProjectsParams extends FlowAuthParams { + limit?: number +} + +export interface FlowListProjectsResponse extends ToolResponse { + output: { projects: ReturnType[] } +} + +export interface FlowGetProjectParams extends FlowAuthParams { + projectId: string +} + +export interface FlowGetProjectResponse extends ToolResponse { + output: { project: ReturnType } +} + +export interface FlowGetDataModelParams extends FlowAuthParams { + projectId: string +} + +export interface FlowGetDataModelResponse extends ToolResponse { + output: { categories: ReturnType[] } +} + +export interface FlowListBranchesParams extends FlowAuthParams { + projectId: string +} + +export interface FlowListBranchesResponse extends ToolResponse { + output: { branches: ReturnType[] } +} + +export interface FlowCreateBranchParams extends FlowAuthParams { + projectId: string + branchName?: string + parentId?: string + description?: string + isVariant?: boolean +} + +export interface FlowCreateBranchResponse extends ToolResponse { + output: { id: string; number: number } +} + +export interface FlowSearchEntitiesParams extends FlowAuthParams { + projectId: string + branchId?: string + entityType?: string + query: string + limit?: number +} + +export interface FlowSearchEntitiesResponse extends ToolResponse { + output: { matches: ReturnType[] } +} + +export interface FlowListEntitiesParams extends FlowAuthParams { + projectId: string + branchId?: string + entityType?: string + cursor?: string + limit?: number +} + +export interface FlowListEntitiesResponse extends ToolResponse { + output: { + entities: ReturnType[] + nextPageStart: string | null + moreAvailable: boolean + } +} + +export interface FlowGetEntitiesParams extends FlowAuthParams { + projectId: string + branchId?: string + entityIds: string +} + +export interface FlowGetEntitiesResponse extends ToolResponse { + output: { entities: ReturnType[] } +} + +export interface FlowCreateEntityParams extends FlowAuthParams { + projectId: string + branchId?: string + entityType: string + name: string +} + +export interface FlowCreateEntityResponse extends ToolResponse { + output: { entity: ReturnType } +} + +export interface FlowCreateEntitiesParams extends FlowAuthParams { + projectId: string + branchId?: string + entities: unknown +} + +export interface FlowCreateEntitiesResponse extends ToolResponse { + output: { entities: ReturnType[] } +} + +export interface FlowSetValueParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string + fieldKey: string + value: string +} + +export interface FlowSetValueResponse extends ToolResponse { + output: { entity: ReturnType } +} + +export interface FlowSetValuesParams extends FlowAuthParams { + projectId: string + branchId?: string + updates: unknown +} + +export interface FlowSetValuesResponse extends ToolResponse { + output: { entities: ReturnType[] } +} + +export interface FlowDeleteEntityParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string +} + +export interface FlowDeleteEntityResponse extends ToolResponse { + output: Record +} + +export interface FlowRestoreEntityParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string +} + +export interface FlowRestoreEntityResponse extends ToolResponse { + output: Record +} + +export interface FlowAddLinkParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string + destinationId: string +} + +export interface FlowAddLinkResponse extends ToolResponse { + output: { entity: ReturnType } +} + +export interface FlowRemoveLinkParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string + destinationId: string +} + +export interface FlowRemoveLinkResponse extends ToolResponse { + output: { entity: ReturnType } +} + +export interface FlowListCommentsParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string + includeResolved?: boolean +} + +export interface FlowListCommentsResponse extends ToolResponse { + output: { comments: ReturnType[] } +} + +export interface FlowGetCommentParams extends FlowAuthParams { + projectId: string + entityId: string + commentId: string +} + +export interface FlowGetCommentResponse extends ToolResponse { + output: { comment: ReturnType } +} + +export interface FlowCreateCommentParams extends FlowAuthParams { + projectId: string + entityId: string + branchId?: string + text: string +} + +export interface FlowCreateCommentResponse extends ToolResponse { + output: { comment: ReturnType } +} + +export interface FlowUpdateCommentParams extends FlowAuthParams { + projectId: string + entityId: string + commentId: string + text?: string + resolved?: boolean +} + +export interface FlowUpdateCommentResponse extends ToolResponse { + output: { comment: ReturnType } +} + +export interface FlowDeleteCommentParams extends FlowAuthParams { + projectId: string + entityId: string + commentId: string +} + +export interface FlowDeleteCommentResponse extends ToolResponse { + output: Record +} + +export interface FlowFilterEntitiesParams extends FlowAuthParams { + projectId: string + branchId?: string + filter: unknown + cursor?: string + limit?: number + timezone?: string +} + +export interface FlowFilterEntitiesResponse extends ToolResponse { + output: { entityIds: string[]; nextCursor: string | null } +} + +export interface FlowGetEntityHistoryParams extends FlowAuthParams { + projectId: string + branchId?: string + entityIds: string + cursor?: string + limit?: number +} + +export interface FlowGetEntityHistoryResponse extends ToolResponse { + output: { entries: ReturnType[]; nextCursor: string | null } +} diff --git a/apps/sim/tools/flow/update-comment.ts b/apps/sim/tools/flow/update-comment.ts new file mode 100644 index 00000000000..4089729e0d6 --- /dev/null +++ b/apps/sim/tools/flow/update-comment.ts @@ -0,0 +1,81 @@ +import type { FlowComment } from '@/lib/flow' +import { flowHeaders } from '@/lib/flow' +import type { FlowUpdateCommentParams, FlowUpdateCommentResponse } from '@/tools/flow/types' +import { + assertFlowResponse, + FLOW_AUTH_PARAMS, + FLOW_COMMENT_PROPERTIES, + FLOW_OAUTH, + FLOW_REDIRECT_POLICY, + flowCommentUpdate, + flowUrl, + mapFlowComment, +} from '@/tools/flow/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const flowUpdateCommentTool: ToolConfig = + { + id: 'flow_update_comment', + name: 'Flow Update Comment', + description: + 'Edit comment text or resolve/reopen its thread. Use the original comment to change the thread status.', + version: '1.0.0', + oauth: FLOW_OAUTH, + params: { + ...FLOW_AUTH_PARAMS, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project UUID returned by List Projects', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entity ID, or the original comment ID when reading or posting thread replies', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Comment ID; entityId must be the comment's returned entity_id", + }, + text: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Replacement plain-text comment body', + }, + resolved: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Resolve the thread with true or reopen it with false', + }, + }, + request: { + url: (params) => + flowUrl( + params, + `/project/${safeUrlPathSegment(params.projectId, 'projectId')}/entities/${safeUrlPathSegment(params.entityId, 'entityId')}/comments/${safeUrlPathSegment(params.commentId, 'commentId')}` + ), + method: 'PATCH', + headers: flowHeaders, + redirectPolicy: () => FLOW_REDIRECT_POLICY, + body: (params) => flowCommentUpdate(params), + }, + transformResponse: async (response) => { + assertFlowResponse(response) + const data = (await response.json()) as FlowComment + return { success: true, output: { comment: mapFlowComment(data) } } + }, + outputs: { + comment: { + type: 'json', + properties: FLOW_COMMENT_PROPERTIES, + description: 'Update Comment result', + }, + }, + } diff --git a/apps/sim/tools/flow/utils.test.ts b/apps/sim/tools/flow/utils.test.ts new file mode 100644 index 00000000000..95411dfcfa2 --- /dev/null +++ b/apps/sim/tools/flow/utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { flowIds } from '@/tools/flow/utils' + +describe('Flow entity ID lists', () => { + it.each(['id-a,,id-b', ',id-a', 'id-a,', 'id-a, \t ,id-b'])( + 'rejects missing IDs in %j rather than reading a partial selection', + (value) => { + expect(() => flowIds(value)).toThrow('entityIds is required') + } + ) + + it('preserves every selected ID while trimming surrounding whitespace', () => { + expect(flowIds(' id-a , id-b ')).toEqual(['id-a', 'id-b']) + }) +}) diff --git a/apps/sim/tools/flow/utils.ts b/apps/sim/tools/flow/utils.ts new file mode 100644 index 00000000000..6832568dd8a --- /dev/null +++ b/apps/sim/tools/flow/utils.ts @@ -0,0 +1,657 @@ +import { isRecordLike } from '@sim/utils/object' +import type { HttpRedirectPolicy } from '@/lib/core/security/http-redirect-policy' +import type { + FlowBranch, + FlowCategory, + FlowComment, + FlowEntity, + FlowHistoryEntry, + FlowProject, + FlowSearchResult, +} from '@/lib/flow' +import { normalizeFlowOrigin } from '@/lib/flow/client' +import type { FlowAuthParams } from '@/tools/flow/types' +import type { OutputProperty, ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const FLOW_AUTH_PARAMS = { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Personal API key resolved from the selected Flow credential', + }, + domain: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Flow API origin bound to the selected credential', + }, + providerWorkspaceId: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Flow workspace identifier bound to the selected credential', + }, +} as const satisfies ToolConfig['params'] + +export const FLOW_OAUTH = { + required: true, + provider: 'flow', + credentialKind: 'service-account', + authoritativeParams: ['domain', 'providerWorkspaceId'], +} as const + +export const FLOW_REDIRECT_POLICY = { + mode: 'standard', + sendCredentialsOnCrossOriginRedirect: false, + sensitiveHeaders: ['x-api-key', 'customer'], +} as const satisfies HttpRedirectPolicy + +/** Builds only provider-owned paths with encoded resource IDs and explicit query parameters. */ +export function flowUrl( + params: FlowAuthParams, + path: string, + query: Record = {} +): string { + const url = new URL(`${normalizeFlowOrigin(params.domain)}${path}`) + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + return url.toString() +} + +export function flowLimit(value: number | undefined, maximum: number, fallback: number): number { + const limit = value === undefined ? fallback : Number(value) + if (!Number.isInteger(limit) || limit < 1 || limit > maximum) { + throw new Error(`Limit must be an integer between 1 and ${maximum}`) + } + return limit +} + +export function flowSearchQuery(value: string): string { + if (typeof value !== 'string' || value.length > 1024) + throw new Error('Query must contain at most 1024 characters') + return value +} + +export function flowIds(value: string, maximum = 100): string[] { + const ids = value.split(',').map((id) => id.trim()) + if (ids.length > maximum) throw new Error(`Provide between 1 and ${maximum} entity IDs`) + for (const id of ids) safeUrlPathSegment(id, 'entityIds') + return ids +} + +function parseFlowJson(value: unknown, name: string): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + throw new Error(`${name} must be valid JSON`) + } +} + +function flowBatch(value: unknown, name: string): Record[] { + const items = parseFlowJson(value, name) + if ( + !Array.isArray(items) || + items.length < 1 || + items.length > 100 || + !items.every(isRecordLike) + ) { + throw new Error(`${name} must be a JSON array containing 1 to 100 objects`) + } + return items +} + +export function flowEntityBatch(value: unknown) { + return flowBatch(value, 'entities').map((item) => { + if ( + typeof item.entityType !== 'string' || + !item.entityType.trim() || + typeof item.name !== 'string' + ) { + throw new Error('Each entity requires entityType and name') + } + if (item.id !== undefined && typeof item.id !== 'string') + throw new Error('Entity id must be a string') + return { entityType: item.entityType.trim(), name: item.name, id: item.id } + }) +} + +export function flowValueBatch(value: unknown) { + return flowBatch(value, 'updates').map((item) => { + if ( + typeof item.entityId !== 'string' || + !item.entityId.trim() || + typeof item.key !== 'string' || + !item.key.trim() || + !Object.hasOwn(item, 'value') + ) { + throw new Error('Each update requires entityId, key, and value') + } + return { entityId: item.entityId.trim(), key: item.key.trim(), value: item.value } + }) +} + +export function flowFilter(value: unknown): Record { + const filter = parseFlowJson(value, 'filter') + if (!isRecordLike(filter)) throw new Error('Filter must be a JSON object') + return filter +} + +export function flowCommentUpdate(params: { text?: string; resolved?: boolean }) { + if (params.text === undefined && params.resolved === undefined) + throw new Error('Provide comment text or a resolved status') + return { text: params.text, resolved: params.resolved } +} + +/** The executor caps provider JSON responses before transformation; no tool follows pages implicitly. */ +export function assertFlowResponse(response: Response): void { + if (!response.ok) throw new Error(`Flow request failed with HTTP ${response.status}`) +} + +export function mapFlowProject(value: FlowProject) { + return { + ai_model: value.ai_model, + created_at: value.created_at, + id: value.id, + name: value.name, + slug: value.slug, + } +} + +export const FLOW_PROJECT_PROPERTIES = { + ai_model: { + type: 'string', + }, + created_at: { + type: 'string', + }, + id: { + type: 'string', + }, + name: { + type: 'string', + }, + slug: { + type: 'string', + }, +} as const satisfies Record + +export function mapFlowCategory(value: FlowCategory) { + return { + builtin: value.builtin, + categoryType: value.categoryType, + fields: value.fields, + humanIdPrefix: value.humanIdPrefix, + humanIdPrefixLocked: value.humanIdPrefixLocked, + id: value.id, + name: value.name, + } +} + +export const FLOW_CATEGORY_PROPERTIES = { + builtin: { + type: 'boolean', + }, + categoryType: { + type: 'string', + }, + fields: { + type: 'array', + items: { + type: 'json', + properties: { + key: { + type: 'string', + }, + multi: { + type: 'boolean', + optional: true, + }, + name: { + type: 'string', + }, + tags: { + type: 'array', + optional: true, + items: { + type: 'json', + properties: { + id: { + type: 'string', + optional: true, + }, + name: { + type: 'string', + }, + }, + }, + }, + type: { + type: 'string', + }, + }, + }, + }, + humanIdPrefix: { + type: 'string', + }, + humanIdPrefixLocked: { + type: 'boolean', + }, + id: { + type: 'string', + }, + name: { + type: 'string', + }, +} as const satisfies Record + +export function mapFlowEntity(value: FlowEntity) { + return { + branchesChangedIn: value.branchesChangedIn ?? [], + branchesCreatedIn: value.branchesCreatedIn ?? [], + createdAt: value.createdAt ?? null, + defaultSortKey: value.defaultSortKey ?? null, + deleted: value.deleted ?? null, + formulaTemplates: value.formulaTemplates, + formulas: value.formulas, + id: value.id, + links: value.links, + type: value.type ?? null, + uniqueEntityNumber: value.uniqueEntityNumber ?? null, + updatedAt: value.updatedAt ?? null, + values: value.values, + } +} + +export const FLOW_ENTITY_PROPERTIES = { + branchesChangedIn: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + branchesCreatedIn: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + createdAt: { + type: 'string', + nullable: true, + }, + defaultSortKey: { + type: 'string', + nullable: true, + }, + deleted: { + type: 'boolean', + nullable: true, + }, + formulaTemplates: { + type: 'json', + }, + formulas: { + type: 'json', + }, + id: { + type: 'string', + }, + links: { + type: 'json', + properties: { + crosslinks: { + type: 'array', + items: { + type: 'string', + }, + }, + inbound: { + type: 'array', + items: { + type: 'string', + }, + }, + inboundOrder: { + type: 'json', + }, + inboundTypes: { + type: 'json', + optional: true, + }, + outbound: { + type: 'array', + items: { + type: 'string', + }, + }, + outboundOrder: { + type: 'json', + optional: true, + }, + outboundTypes: { + type: 'json', + optional: true, + }, + rootPlaced: { + type: 'boolean', + optional: true, + }, + }, + }, + type: { + type: 'string', + nullable: true, + }, + uniqueEntityNumber: { + type: 'number', + nullable: true, + }, + updatedAt: { + type: 'string', + nullable: true, + }, + values: { + type: 'json', + }, +} as const satisfies Record + +export function mapFlowSearchResult(value: FlowSearchResult) { + return { + entity_id: value.entity_id, + entity_type: value.entity_type, + human_id: value.human_id ?? null, + legacy_human_id: value.legacy_human_id ?? null, + match: value.match ?? null, + name: value.name, + same_text_count: value.same_text_count ?? null, + } +} + +export const FLOW_SEARCH_RESULT_PROPERTIES = { + entity_id: { + type: 'string', + }, + entity_type: { + type: 'string', + }, + human_id: { + type: 'string', + nullable: true, + }, + legacy_human_id: { + type: 'string', + nullable: true, + }, + match: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + }, + same_text_count: { + type: 'number', + nullable: true, + }, +} as const satisfies Record + +export function mapFlowBranch(value: FlowBranch) { + return { + created_at: value.created_at, + description: value.description, + id: value.id, + is_deleted: value.is_deleted, + is_protected: value.is_protected, + live_edits_allowed: value.live_edits_allowed, + merged_at: value.merged_at ?? null, + name: value.name, + number: value.number, + owner_id: value.owner_id ?? null, + parent: value.parent ?? null, + parent_name: value.parent_name ?? null, + ready_for_review: value.ready_for_review, + require_review: value.require_review, + required_reviewer_ids: value.required_reviewer_ids, + updated_at: value.updated_at, + } +} + +export const FLOW_BRANCH_PROPERTIES = { + created_at: { + type: 'string', + }, + description: { + type: 'string', + }, + id: { + type: 'string', + }, + is_deleted: { + type: 'boolean', + }, + is_protected: { + type: 'boolean', + }, + live_edits_allowed: { + type: 'boolean', + }, + merged_at: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + }, + number: { + type: 'number', + }, + owner_id: { + type: 'string', + nullable: true, + }, + parent: { + type: 'string', + nullable: true, + }, + parent_name: { + type: 'string', + nullable: true, + }, + ready_for_review: { + type: 'boolean', + }, + require_review: { + type: 'boolean', + }, + required_reviewer_ids: { + type: 'array', + items: { + type: 'string', + }, + }, + updated_at: { + type: 'string', + }, +} as const satisfies Record + +export function mapFlowComment(value: FlowComment) { + return { + author_id: value.author_id, + branch_id: value.branch_id ?? null, + created_at: value.created_at, + entity_id: value.entity_id, + id: value.id, + resolved: value.resolved, + text: value.text, + } +} + +export const FLOW_COMMENT_PROPERTIES = { + author_id: { + type: 'string', + description: 'ID of the user who authored the comment.', + }, + branch_id: { + type: 'string', + description: 'Branch the comment was posted on; null for the main line.', + nullable: true, + }, + created_at: { + type: 'string', + }, + entity_id: { + type: 'string', + description: + 'ID this comment directly attaches to: an item or branch ID for a conversation starter, or a comment ID for a reply (normally the original comment that started the conversation). Use this value as entityId when fetching, editing or deleting this comment. For older nested replies it may identify another reply, not the conversation starter.', + }, + id: { + type: 'string', + }, + resolved: { + type: 'boolean', + description: 'Whether the comment thread has been resolved.', + }, + text: { + type: 'string', + description: 'Plain-text body of the comment.', + }, +} as const satisfies Record + +export function mapFlowHistoryEntry(value: FlowHistoryEntry) { + return { + actor: value.actor, + branch_id: value.branch_id, + change: value.change, + id: value.id, + reason: value.reason ?? null, + timestamp: value.timestamp, + } +} + +export const FLOW_HISTORY_ENTRY_PROPERTIES = { + actor: { + type: 'json', + properties: { + id: { + type: 'string', + description: 'User or agent ID. Omitted for system and unknown actors.', + optional: true, + }, + type: { + type: 'string', + }, + }, + }, + branch_id: { + type: 'string', + description: 'Branch that authored this event; "master" means Base.', + }, + change: { + type: 'json', + properties: { + entity_id: { + type: 'string', + optional: true, + }, + entity_type: { + type: 'string', + optional: true, + }, + type: { + type: 'string', + optional: true, + }, + field_key: { + type: 'string', + optional: true, + }, + value: { + type: 'json', + optional: true, + nullable: true, + properties: {}, + }, + formula: { + type: 'string', + optional: true, + nullable: true, + }, + child_entity_id: { + type: 'string', + optional: true, + }, + parent_entity_id: { + type: 'string', + optional: true, + }, + entity_ids: { + type: 'array', + description: 'The two peer entity IDs for this unordered cross-link.', + optional: true, + items: { + type: 'string', + }, + }, + action: { + type: 'string', + optional: true, + }, + note: { + type: 'string', + optional: true, + }, + source_entity_id: { + type: 'string', + optional: true, + }, + source_name: { + type: 'string', + optional: true, + }, + }, + }, + id: { + type: 'string', + description: 'Stable event ID for deduplication.', + }, + reason: { + type: 'json', + nullable: true, + properties: { + source_entity_id: { + type: 'string', + optional: true, + }, + source_field_key: { + type: 'string', + optional: true, + }, + type: { + type: 'string', + optional: true, + }, + field_name: { + type: 'string', + optional: true, + }, + next_type: { + type: 'string', + optional: true, + }, + previous_type: { + type: 'string', + optional: true, + }, + }, + }, + timestamp: { + type: 'string', + }, +} as const satisfies Record diff --git a/apps/sim/tools/gauge/add_tags_to_prompts.ts b/apps/sim/tools/gauge/add_tags_to_prompts.ts new file mode 100644 index 00000000000..c799dc4db63 --- /dev/null +++ b/apps/sim/tools/gauge/add_tags_to_prompts.ts @@ -0,0 +1,71 @@ +import type { + GaugeAddTagsToPromptsParams, + GaugeAddTagsToPromptsResponse, + GaugePromptTagsOut, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeAddTagsToPromptsTool: ToolConfig< + GaugeAddTagsToPromptsParams, + GaugeAddTagsToPromptsResponse +> = { + id: 'gauge_add_tags_to_prompts', + name: 'Gauge Add Tags To Prompts', + description: 'Apply a set of tags to the selected tracked prompts.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + promptIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of 1–500 prompt IDs', + }, + tagIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of 1–50 tag IDs', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts/tags` + ), + method: 'POST', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ + promptIds: parseGaugeStringArray(params.promptIds, 'promptIds', { min: 1, max: 500 }), + tagIds: parseGaugeStringArray(params.tagIds, 'tagIds', { min: 1, max: 50 }), + }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugePromptTagsOut + return { success: true, output: { promptIds: data.promptIds ?? [] } } + }, + outputs: { + promptIds: { type: 'array', items: { type: 'string' }, description: 'Prompt ids' }, + }, +} diff --git a/apps/sim/tools/gauge/bulk_update_prompts.ts b/apps/sim/tools/gauge/bulk_update_prompts.ts new file mode 100644 index 00000000000..d9278583b5e --- /dev/null +++ b/apps/sim/tools/gauge/bulk_update_prompts.ts @@ -0,0 +1,79 @@ +import type { + GaugeBulkPatchOut, + GaugeBulkUpdatePromptsParams, + GaugeBulkUpdatePromptsResponse, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeBulkUpdatePromptsTool: ToolConfig< + GaugeBulkUpdatePromptsParams, + GaugeBulkUpdatePromptsResponse +> = { + id: 'gauge_bulk_update_prompts', + name: 'Gauge Bulk Update Prompts', + description: 'Change the topic or tracking type of up to 500 tracked prompts.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + topicId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New topic ID; omitted values leave the topic unchanged', + }, + trackingType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt tracking type: visibility or branded_own', + }, + ids: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of 1–500 prompt IDs to update', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts` + ), + method: 'PATCH', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ + topicId: params.topicId, + trackingType: params.trackingType, + ids: parseGaugeStringArray(params.ids, 'ids', { min: 1, max: 500 }), + }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeBulkPatchOut + return { success: true, output: { updated: data.updated ?? [], notFound: data.notFound ?? [] } } + }, + outputs: { + updated: { type: 'array', items: { type: 'string' }, description: 'Updated' }, + notFound: { type: 'array', items: { type: 'string' }, description: 'Not found' }, + }, +} diff --git a/apps/sim/tools/gauge/create_prompts.ts b/apps/sim/tools/gauge/create_prompts.ts new file mode 100644 index 00000000000..206c7c9007d --- /dev/null +++ b/apps/sim/tools/gauge/create_prompts.ts @@ -0,0 +1,175 @@ +import { isRecordLike } from '@sim/utils/object' +import { CREATE_RESULT_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeCreatePromptsOut, + GaugeCreatePromptsParams, + GaugeCreatePromptsResponse, + GaugePromptIn, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeJson, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +const MAX_PROMPT_TEXT_LENGTH = 500 + +/** Matches Gauge's code-point limit without expanding oversized strings. */ +function isPromptTextWithinLimit(text: string): boolean { + return ( + text.length <= MAX_PROMPT_TEXT_LENGTH || + (text.length <= MAX_PROMPT_TEXT_LENGTH * 2 && Array.from(text).length <= MAX_PROMPT_TEXT_LENGTH) + ) +} + +/** Validates the batch before selecting only the text Gauge sends to AI engines. */ +function parseGaugePrompts(value: unknown): GaugePromptIn[] { + const parsed = parseGaugeJson(value, 'prompts') + if (!Array.isArray(parsed) || parsed.length < 1 || parsed.length > 500) { + throw new Error('prompts must be a JSON array containing 1 to 500 prompts') + } + return parsed.map((prompt) => { + if ( + !isRecordLike(prompt) || + typeof prompt.text !== 'string' || + !prompt.text.trim() || + !isPromptTextWithinLimit(prompt.text) || + typeof prompt.topicId !== 'string' || + !prompt.topicId.trim() + ) { + throw new Error('Each prompt must have text (1 to 500 characters) and a topicId') + } + if ( + prompt.trackingType !== undefined && + prompt.trackingType !== 'visibility' && + prompt.trackingType !== 'branded_own' + ) { + throw new Error('Prompt trackingType must be visibility or branded_own') + } + return { + text: prompt.text, + topicId: prompt.topicId, + ...(prompt.trackingType !== undefined ? { trackingType: prompt.trackingType } : {}), + ...(prompt.tagIds !== undefined + ? { tagIds: parseGaugeStringArray(prompt.tagIds, 'prompt tagIds') } + : {}), + } + }) +} + +/** Selects model-visible prompt text without projecting topic IDs, tags, or control fields. */ +function selectGaugePromptText(params: { prompts: unknown }): Record { + return { prompts: parseGaugePrompts(params.prompts).map((prompt) => prompt.text) } +} + +/** Rebuilds the original JSON representation after the shared executor projects prompt text. */ +function rebuildGaugePrompts(original: unknown, projectedText: unknown): string | GaugePromptIn[] { + const prompts = parseGaugePrompts(original) + if (!Array.isArray(projectedText) || projectedText.length !== prompts.length) { + throw new Error('Projected Gauge prompts must match the original prompt batch') + } + const texts = projectedText.map((text: unknown) => { + if (typeof text !== 'string') { + throw new Error('Projected Gauge prompts must match the original prompt batch') + } + return text + }) + const rebuilt = prompts.map((prompt, index) => ({ ...prompt, text: texts[index] ?? '' })) + return typeof original === 'string' ? JSON.stringify(rebuilt) : rebuilt +} + +export const gaugeCreatePromptsTool: ToolConfig< + GaugeCreatePromptsParams, + GaugeCreatePromptsResponse +> = { + id: 'gauge_create_prompts', + name: 'Gauge Create Prompts', + description: 'Add a batch of tracked questions or preview the import without creating prompts.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + dryRun: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Validate the batch and report what would be created without saving prompts', + }, + prompts: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of 1–500 prompts: [{"text":"Which tools work best?","topicId":"top_example","trackingType":"visibility","tagIds":[]}]', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts`, + { dryRun: params.dryRun } + ), + method: 'POST', + headers: (params) => gaugeHeaders(params.accessToken, true), + modelInput: { + mode: 'project', + select: selectGaugePromptText, + applyProjected: (selectedParams, projectedSelection) => ({ + prompts: rebuildGaugePrompts(selectedParams.prompts, projectedSelection.prompts), + }), + }, + body: (params) => ({ prompts: parseGaugePrompts(params.prompts) }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeCreatePromptsOut + return { + success: true, + output: { + results: (data.results ?? []).map((item) => ({ + index: item.index, + status: item.status, + prompt: + item.prompt == null + ? null + : { + id: item.prompt.id, + text: item.prompt.text, + topicId: item.prompt.topicId ?? null, + trackingType: item.prompt.trackingType, + tagIds: item.prompt.tagIds ?? [], + createdAt: item.prompt.createdAt, + firstAnswerDay: item.prompt.firstAnswerDay ?? null, + }, + })), + dryRun: data.dryRun, + remaining: data.remaining ?? null, + }, + } + }, + outputs: { + results: { + type: 'array', + items: { type: 'object', properties: CREATE_RESULT_PROPERTIES }, + description: 'Results', + }, + dryRun: { type: 'boolean', description: 'Dry run' }, + remaining: { type: 'number', description: 'Remaining', nullable: true }, + }, +} diff --git a/apps/sim/tools/gauge/create_tag.ts b/apps/sim/tools/gauge/create_tag.ts new file mode 100644 index 00000000000..d81bd411bfb --- /dev/null +++ b/apps/sim/tools/gauge/create_tag.ts @@ -0,0 +1,52 @@ +import { TAG_PROPERTIES } from '@/tools/gauge/outputs' +import type { GaugeCreateTagParams, GaugeCreateTagResponse, GaugeTag } from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeCreateTagTool: ToolConfig = { + id: 'gauge_create_tag', + name: 'Gauge Create Tag', + description: 'Create an organization-wide prompt tag.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tag name (1–120 characters)', + }, + color: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag color string (up to 20 characters)', + }, + }, + request: { + url: (params) => buildGaugeUrl(`/orgs/${safeUrlPathSegment(params.org, 'org')}/tags`), + method: 'POST', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ name: params.name, color: params.color }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTag + return { + success: true, + output: { + tag: { id: data.id, name: data.name, color: data.color, promptCount: data.promptCount }, + }, + } + }, + outputs: { + tag: { type: 'json', properties: TAG_PROPERTIES, description: 'Tag' }, + }, +} diff --git a/apps/sim/tools/gauge/create_topic.ts b/apps/sim/tools/gauge/create_topic.ts new file mode 100644 index 00000000000..5106c5c50b3 --- /dev/null +++ b/apps/sim/tools/gauge/create_topic.ts @@ -0,0 +1,65 @@ +import { TOPIC_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeCreateTopicParams, + GaugeCreateTopicResponse, + GaugeTopic, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeCreateTopicTool: ToolConfig = { + id: 'gauge_create_topic', + name: 'Gauge Create Topic', + description: 'Create a topic to organize tracked prompts.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Topic title (1–200 characters)', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/topics` + ), + method: 'POST', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ title: params.title }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTopic + return { + success: true, + output: { + topic: { + id: data.id, + slug: data.slug, + title: data.title, + promptCount: data.promptCount, + createdAt: data.createdAt, + }, + }, + } + }, + outputs: { + topic: { type: 'json', properties: TOPIC_PROPERTIES, description: 'Topic' }, + }, +} diff --git a/apps/sim/tools/gauge/delete_tag.ts b/apps/sim/tools/gauge/delete_tag.ts new file mode 100644 index 00000000000..7197f57a73d --- /dev/null +++ b/apps/sim/tools/gauge/delete_tag.ts @@ -0,0 +1,38 @@ +import type { GaugeDeleteTagParams, GaugeDeleteTagResponse } from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeDeleteTagTool: ToolConfig = { + id: 'gauge_delete_tag', + name: 'Gauge Delete Tag', + description: 'Delete a prompt tag and remove its associations with prompts.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + tagId: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Tag ID' }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/tags/${safeUrlPathSegment(params.tagId, 'tagId')}` + ), + method: 'DELETE', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (_response, params) => { + if (!params) throw new Error('Gauge delete tag requires request context') + return { success: true, output: { tagId: params.tagId.trim(), deleted: true } } + }, + outputs: { + tagId: { type: 'string', description: 'ID of the deleted tag' }, + deleted: { type: 'boolean', description: 'Whether the tag was deleted' }, + }, +} diff --git a/apps/sim/tools/gauge/get_answer.ts b/apps/sim/tools/gauge/get_answer.ts new file mode 100644 index 00000000000..853742962e0 --- /dev/null +++ b/apps/sim/tools/gauge/get_answer.ts @@ -0,0 +1,80 @@ +import { ANSWER_DETAIL_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeAnswerDetail, + GaugeGetAnswerParams, + GaugeGetAnswerResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetAnswerTool: ToolConfig = { + id: 'gauge_get_answer', + name: 'Gauge Get Answer', + description: 'Read an AI answer with its prompt, brand mentions, source citations, and searches.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + answerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Answer ID', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/answers/${safeUrlPathSegment(params.answerId, 'answerId')}` + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeAnswerDetail + return { + success: true, + output: { + answer: { + id: data.id, + promptId: data.promptId, + promptText: data.promptText, + topicId: data.topicId ?? null, + model: data.model, + day: data.day, + createdAt: data.createdAt, + text: data.text, + mentions: (data.mentions ?? []).map((item) => ({ + brandId: item.brandId, + name: item.name, + mentionTypes: item.mentionTypes ?? [], + count: item.count, + })), + citations: (data.citations ?? []).map((item) => ({ + pageId: item.pageId, + url: item.url, + title: item.title ?? null, + domain: item.domain, + })), + searchQueries: data.searchQueries ?? [], + }, + }, + } + }, + outputs: { + answer: { type: 'json', properties: ANSWER_DETAIL_PROPERTIES, description: 'Answer' }, + }, +} diff --git a/apps/sim/tools/gauge/get_category.ts b/apps/sim/tools/gauge/get_category.ts new file mode 100644 index 00000000000..23203411af0 --- /dev/null +++ b/apps/sim/tools/gauge/get_category.ts @@ -0,0 +1,52 @@ +import { CATEGORY_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeCategory, + GaugeGetCategoryParams, + GaugeGetCategoryResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetCategoryTool: ToolConfig = { + id: 'gauge_get_category', + name: 'Gauge Get Category', + description: 'Read the details of an AI-search category.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}` + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeCategory + return { + success: true, + output: { + category: { id: data.id, slug: data.slug, name: data.name, description: data.description }, + }, + } + }, + outputs: { + category: { type: 'json', properties: CATEGORY_PROPERTIES, description: 'Category' }, + }, +} diff --git a/apps/sim/tools/gauge/get_current_credential.ts b/apps/sim/tools/gauge/get_current_credential.ts new file mode 100644 index 00000000000..967b8a44c93 --- /dev/null +++ b/apps/sim/tools/gauge/get_current_credential.ts @@ -0,0 +1,71 @@ +import { + API_KEY_INFO_PROPERTIES, + TOKEN_INFO_PROPERTIES, + USER_INFO_PROPERTIES, +} from '@/tools/gauge/outputs' +import type { + GaugeGetCurrentCredentialParams, + GaugeGetCurrentCredentialResponse, + GaugeMe, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' + +export const gaugeGetCurrentCredentialTool: ToolConfig< + GaugeGetCurrentCredentialParams, + GaugeGetCurrentCredentialResponse +> = { + id: 'gauge_get_current_credential', + name: 'Gauge Get Current Credential', + description: 'Read the identity, permissions, and expiry of the connected Gauge API key.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + }, + request: { + url: () => buildGaugeUrl(`/me`), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeMe + return { + success: true, + output: { + user: + data.user == null + ? null + : { id: data.user.id, email: data.user.email, name: data.user.name }, + apiKey: + data.apiKey == null + ? null + : { + id: data.apiKey.id, + name: data.apiKey.name, + prefix: data.apiKey.prefix, + organization: { + id: data.apiKey.organization.id, + slug: data.apiKey.organization.slug, + name: data.apiKey.organization.name, + }, + }, + token: { + kind: data.token.kind, + scopes: data.token.scopes ?? [], + expiresAt: data.token.expiresAt ?? null, + }, + }, + } + }, + outputs: { + user: { type: 'json', properties: USER_INFO_PROPERTIES, description: 'User', nullable: true }, + apiKey: { + type: 'json', + properties: API_KEY_INFO_PROPERTIES, + description: 'Api key', + nullable: true, + }, + token: { type: 'json', properties: TOKEN_INFO_PROPERTIES, description: 'Token' }, + }, +} diff --git a/apps/sim/tools/gauge/get_organization.ts b/apps/sim/tools/gauge/get_organization.ts new file mode 100644 index 00000000000..959e8f0923c --- /dev/null +++ b/apps/sim/tools/gauge/get_organization.ts @@ -0,0 +1,55 @@ +import { DIRECTORY_ORGANIZATION_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeDirectoryOrganization, + GaugeGetOrganizationParams, + GaugeGetOrganizationResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetOrganizationTool: ToolConfig< + GaugeGetOrganizationParams, + GaugeGetOrganizationResponse +> = { + id: 'gauge_get_organization', + name: 'Gauge Get Organization', + description: 'Read an organization and its enabled Gauge products.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + }, + request: { + url: (params) => buildGaugeUrl(`/orgs/${safeUrlPathSegment(params.org, 'org')}`), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeDirectoryOrganization + return { + success: true, + output: { + organization: { + id: data.id, + slug: data.slug, + name: data.name, + products: { chat: data.products.chat, agents: data.products.agents ?? null }, + }, + }, + } + }, + outputs: { + organization: { + type: 'json', + properties: DIRECTORY_ORGANIZATION_PROPERTIES, + description: 'Organization', + }, + }, +} diff --git a/apps/sim/tools/gauge/get_prompt.ts b/apps/sim/tools/gauge/get_prompt.ts new file mode 100644 index 00000000000..8880b9e64ab --- /dev/null +++ b/apps/sim/tools/gauge/get_prompt.ts @@ -0,0 +1,66 @@ +import { PROMPT_OUT_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeGetPromptParams, + GaugeGetPromptResponse, + GaugePromptOut, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetPromptTool: ToolConfig = { + id: 'gauge_get_prompt', + name: 'Gauge Get Prompt', + description: 'Read a tracked question and its topic, tags, and sampling information.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + promptId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Prompt ID', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts/${safeUrlPathSegment(params.promptId, 'promptId')}` + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugePromptOut + return { + success: true, + output: { + prompt: { + id: data.id, + text: data.text, + topicId: data.topicId ?? null, + trackingType: data.trackingType, + tagIds: data.tagIds ?? [], + createdAt: data.createdAt, + firstAnswerDay: data.firstAnswerDay ?? null, + }, + }, + } + }, + outputs: { + prompt: { type: 'json', properties: PROMPT_OUT_PROPERTIES, description: 'Prompt' }, + }, +} diff --git a/apps/sim/tools/gauge/get_query_metadata.ts b/apps/sim/tools/gauge/get_query_metadata.ts new file mode 100644 index 00000000000..b48bd6d041a --- /dev/null +++ b/apps/sim/tools/gauge/get_query_metadata.ts @@ -0,0 +1,51 @@ +import type { + GaugeGetQueryMetadataParams, + GaugeGetQueryMetadataResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetQueryMetadataTool: ToolConfig< + GaugeGetQueryMetadataParams, + GaugeGetQueryMetadataResponse +> = { + id: 'gauge_get_query_metadata', + name: 'Gauge Get Query Metadata', + description: 'Discover available report datasets, metrics, dimensions, filters, and values.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + dataset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Report dataset: answers, citations, searchQueries, or aiTraffic', + }, + }, + request: { + url: (params) => + buildGaugeUrl(`/orgs/${safeUrlPathSegment(params.org, 'org')}/query/metadata`, { + dataset: params.dataset, + }), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as Record + return { success: true, output: { catalog: data } } + }, + outputs: { + catalog: { + type: 'json', + description: 'Dynamic dataset, metric, dimension, and filter catalog returned by Gauge', + }, + }, +} diff --git a/apps/sim/tools/gauge/get_tag.ts b/apps/sim/tools/gauge/get_tag.ts new file mode 100644 index 00000000000..717af136978 --- /dev/null +++ b/apps/sim/tools/gauge/get_tag.ts @@ -0,0 +1,43 @@ +import { TAG_PROPERTIES } from '@/tools/gauge/outputs' +import type { GaugeGetTagParams, GaugeGetTagResponse, GaugeTag } from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetTagTool: ToolConfig = { + id: 'gauge_get_tag', + name: 'Gauge Get Tag', + description: 'Read a prompt tag and its usage count.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + tagId: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Tag ID' }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/tags/${safeUrlPathSegment(params.tagId, 'tagId')}` + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTag + return { + success: true, + output: { + tag: { id: data.id, name: data.name, color: data.color, promptCount: data.promptCount }, + }, + } + }, + outputs: { + tag: { type: 'json', properties: TAG_PROPERTIES, description: 'Tag' }, + }, +} diff --git a/apps/sim/tools/gauge/get_topic.ts b/apps/sim/tools/gauge/get_topic.ts new file mode 100644 index 00000000000..1f06b0d6673 --- /dev/null +++ b/apps/sim/tools/gauge/get_topic.ts @@ -0,0 +1,55 @@ +import { TOPIC_PROPERTIES } from '@/tools/gauge/outputs' +import type { GaugeGetTopicParams, GaugeGetTopicResponse, GaugeTopic } from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeGetTopicTool: ToolConfig = { + id: 'gauge_get_topic', + name: 'Gauge Get Topic', + description: 'Read a topic and its tracked-prompt count.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + topicId: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Topic ID' }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/topics/${safeUrlPathSegment(params.topicId, 'topicId')}` + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTopic + return { + success: true, + output: { + topic: { + id: data.id, + slug: data.slug, + title: data.title, + promptCount: data.promptCount, + createdAt: data.createdAt, + }, + }, + } + }, + outputs: { + topic: { type: 'json', properties: TOPIC_PROPERTIES, description: 'Topic' }, + }, +} diff --git a/apps/sim/tools/gauge/index.ts b/apps/sim/tools/gauge/index.ts new file mode 100644 index 00000000000..c078677b2cd --- /dev/null +++ b/apps/sim/tools/gauge/index.ts @@ -0,0 +1,28 @@ +export { gaugeAddTagsToPromptsTool } from './add_tags_to_prompts' +export { gaugeBulkUpdatePromptsTool } from './bulk_update_prompts' +export { gaugeCreatePromptsTool } from './create_prompts' +export { gaugeCreateTagTool } from './create_tag' +export { gaugeCreateTopicTool } from './create_topic' +export { gaugeDeleteTagTool } from './delete_tag' +export { gaugeGetAnswerTool } from './get_answer' +export { gaugeGetCategoryTool } from './get_category' +export { gaugeGetCurrentCredentialTool } from './get_current_credential' +export { gaugeGetOrganizationTool } from './get_organization' +export { gaugeGetPromptTool } from './get_prompt' +export { gaugeGetQueryMetadataTool } from './get_query_metadata' +export { gaugeGetTagTool } from './get_tag' +export { gaugeGetTopicTool } from './get_topic' +export { gaugeListAnswersTool } from './list_answers' +export { gaugeListBrandsTool } from './list_brands' +export { gaugeListCategoriesTool } from './list_categories' +export { gaugeListCitationsTool } from './list_citations' +export { gaugeListOrganizationsTool } from './list_organizations' +export { gaugeListPromptsTool } from './list_prompts' +export { gaugeListTagsTool } from './list_tags' +export { gaugeListTopicsTool } from './list_topics' +export { gaugeQueryTool } from './query' +export { gaugeRemoveTagsFromPromptsTool } from './remove_tags_from_prompts' +export { gaugeRenameTopicTool } from './rename_topic' +export * from './types' +export { gaugeUpdatePromptTool } from './update_prompt' +export { gaugeUpdateTagTool } from './update_tag' diff --git a/apps/sim/tools/gauge/list_answers.ts b/apps/sim/tools/gauge/list_answers.ts new file mode 100644 index 00000000000..c629e096367 --- /dev/null +++ b/apps/sim/tools/gauge/list_answers.ts @@ -0,0 +1,158 @@ +import { ANSWER_ITEM_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeAnswerList, + GaugeListAnswersParams, + GaugeListAnswersResponse, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListAnswersTool: ToolConfig = { + id: 'gauge_list_answers', + name: 'Gauge List Answers', + description: + 'List one page of collected AI answers using date, model, prompt, and brand filters.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + since: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Start date in YYYY-MM-DD format; defaults to 7 days ago', + }, + until: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date in YYYY-MM-DD format; defaults to today', + }, + model: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of model IDs', + }, + prompt: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of prompt IDs', + }, + topic: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of topic IDs', + }, + tag: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of tag IDs', + }, + trackingType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt tracking type: visibility or branded_own or all', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque nextCursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return (1–100; defaults to 50)', + }, + brand: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of brand IDs that an answer must mention', + }, + brandAbsent: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of brand IDs that an answer must not mention', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/answers`, + { + since: params.since, + until: params.until, + model: parseGaugeStringArray(params.model, 'model'), + prompt: parseGaugeStringArray(params.prompt, 'prompt'), + topic: parseGaugeStringArray(params.topic, 'topic'), + tag: parseGaugeStringArray(params.tag, 'tag'), + trackingType: params.trackingType, + cursor: params.cursor, + limit: params.limit, + brand: parseGaugeStringArray(params.brand, 'brand'), + brandAbsent: parseGaugeStringArray(params.brandAbsent, 'brandAbsent'), + } + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeAnswerList + return { + success: true, + output: { + answers: (data.data ?? []).map((item) => ({ + id: item.id, + promptId: item.promptId, + topicId: item.topicId ?? null, + model: item.model, + day: item.day, + createdAt: item.createdAt, + text: item.text, + brandIds: item.brandIds ?? [], + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + answers: { + type: 'array', + items: { type: 'object', properties: ANSWER_ITEM_PROPERTIES }, + description: 'Answers', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_brands.ts b/apps/sim/tools/gauge/list_brands.ts new file mode 100644 index 00000000000..5e4a3bf65a9 --- /dev/null +++ b/apps/sim/tools/gauge/list_brands.ts @@ -0,0 +1,70 @@ +import { BRAND_OUT_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeBrandList, + GaugeListBrandsParams, + GaugeListBrandsResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListBrandsTool: ToolConfig = { + id: 'gauge_list_brands', + name: 'Gauge List Brands', + description: + "Your owned brands and the category's tracked competitors. Together these are the share-of-voice denominator.", + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/brands` + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeBrandList + return { + success: true, + output: { + brands: (data.data ?? []).map((item) => ({ + id: item.id, + name: item.name, + slug: item.slug, + website: item.website, + role: item.role, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + brands: { + type: 'array', + items: { type: 'object', properties: BRAND_OUT_PROPERTIES }, + description: 'Brands', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_categories.ts b/apps/sim/tools/gauge/list_categories.ts new file mode 100644 index 00000000000..2242d61ee11 --- /dev/null +++ b/apps/sim/tools/gauge/list_categories.ts @@ -0,0 +1,62 @@ +import { CATEGORY_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeCategoryList, + GaugeListCategoriesParams, + GaugeListCategoriesResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListCategoriesTool: ToolConfig< + GaugeListCategoriesParams, + GaugeListCategoriesResponse +> = { + id: 'gauge_list_categories', + name: 'Gauge List Categories', + description: 'List the AI-search categories configured for an organization.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + }, + request: { + url: (params) => buildGaugeUrl(`/orgs/${safeUrlPathSegment(params.org, 'org')}/categories`), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeCategoryList + return { + success: true, + output: { + categories: (data.data ?? []).map((item) => ({ + id: item.id, + slug: item.slug, + name: item.name, + description: item.description, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + categories: { + type: 'array', + items: { type: 'object', properties: CATEGORY_PROPERTIES }, + description: 'Categories', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_citations.ts b/apps/sim/tools/gauge/list_citations.ts new file mode 100644 index 00000000000..e78abd0d402 --- /dev/null +++ b/apps/sim/tools/gauge/list_citations.ts @@ -0,0 +1,159 @@ +import { CITATION_ITEM_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeCitationList, + GaugeListCitationsParams, + GaugeListCitationsResponse, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListCitationsTool: ToolConfig< + GaugeListCitationsParams, + GaugeListCitationsResponse +> = { + id: 'gauge_list_citations', + name: 'Gauge List Citations', + description: 'List one page of sources cited by AI answers in a category.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + since: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Start date in YYYY-MM-DD format; defaults to 7 days ago', + }, + until: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date in YYYY-MM-DD format; defaults to today', + }, + model: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of model IDs', + }, + prompt: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of prompt IDs', + }, + topic: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of topic IDs', + }, + tag: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of tag IDs', + }, + trackingType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt tracking type: visibility or branded_own or all', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque nextCursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return (1–200; defaults to 50)', + }, + domain: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of cited domains', + }, + page: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of cited page IDs', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/citations`, + { + since: params.since, + until: params.until, + model: parseGaugeStringArray(params.model, 'model'), + prompt: parseGaugeStringArray(params.prompt, 'prompt'), + topic: parseGaugeStringArray(params.topic, 'topic'), + tag: parseGaugeStringArray(params.tag, 'tag'), + trackingType: params.trackingType, + cursor: params.cursor, + limit: params.limit, + domain: parseGaugeStringArray(params.domain, 'domain'), + page: parseGaugeStringArray(params.page, 'page'), + } + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeCitationList + return { + success: true, + output: { + citations: (data.data ?? []).map((item) => ({ + answerId: item.answerId, + pageId: item.pageId, + promptId: item.promptId, + model: item.model, + day: item.day, + domain: item.domain, + url: item.url, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + citations: { + type: 'array', + items: { type: 'object', properties: CITATION_ITEM_PROPERTIES }, + description: 'Citations', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_organizations.ts b/apps/sim/tools/gauge/list_organizations.ts new file mode 100644 index 00000000000..67a42465c7c --- /dev/null +++ b/apps/sim/tools/gauge/list_organizations.ts @@ -0,0 +1,55 @@ +import { DIRECTORY_ORGANIZATION_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeListOrganizationsParams, + GaugeListOrganizationsResponse, + GaugeOrganizationList, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' + +export const gaugeListOrganizationsTool: ToolConfig< + GaugeListOrganizationsParams, + GaugeListOrganizationsResponse +> = { + id: 'gauge_list_organizations', + name: 'Gauge List Organizations', + description: 'List organizations and agency clients accessible to the connected API key.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + }, + request: { + url: () => buildGaugeUrl(`/orgs`), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeOrganizationList + return { + success: true, + output: { + organizations: (data.data ?? []).map((item) => ({ + id: item.id, + slug: item.slug, + name: item.name, + products: { chat: item.products.chat, agents: item.products.agents ?? null }, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + organizations: { + type: 'array', + items: { type: 'object', properties: DIRECTORY_ORGANIZATION_PROPERTIES }, + description: 'Organizations', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_prompts.ts b/apps/sim/tools/gauge/list_prompts.ts new file mode 100644 index 00000000000..63b7744575a --- /dev/null +++ b/apps/sim/tools/gauge/list_prompts.ts @@ -0,0 +1,115 @@ +import { PROMPT_OUT_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeListPromptsParams, + GaugeListPromptsResponse, + GaugePromptList, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListPromptsTool: ToolConfig = { + id: 'gauge_list_prompts', + name: 'Gauge List Prompts', + description: + 'List one page of tracked prompts with optional topic, tag, and tracking-type filters.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + topic: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by a single topic ID', + }, + tag: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of tag IDs', + }, + trackingType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt tracking type: visibility or branded_own or all', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque nextCursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return (1–200; defaults to 50)', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts`, + { + topic: params.topic, + tag: parseGaugeStringArray(params.tag, 'tag'), + trackingType: params.trackingType, + cursor: params.cursor, + limit: params.limit, + } + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugePromptList + return { + success: true, + output: { + prompts: (data.data ?? []).map((item) => ({ + id: item.id, + text: item.text, + topicId: item.topicId ?? null, + trackingType: item.trackingType, + tagIds: item.tagIds ?? [], + createdAt: item.createdAt, + firstAnswerDay: item.firstAnswerDay ?? null, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + prompts: { + type: 'array', + items: { type: 'object', properties: PROMPT_OUT_PROPERTIES }, + description: 'Prompts', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_tags.ts b/apps/sim/tools/gauge/list_tags.ts new file mode 100644 index 00000000000..b13c7e94b4c --- /dev/null +++ b/apps/sim/tools/gauge/list_tags.ts @@ -0,0 +1,55 @@ +import { TAG_PROPERTIES } from '@/tools/gauge/outputs' +import type { GaugeListTagsParams, GaugeListTagsResponse, GaugeTagList } from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListTagsTool: ToolConfig = { + id: 'gauge_list_tags', + name: 'Gauge List Tags', + description: 'List the tags available to organize prompts in an organization.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + }, + request: { + url: (params) => buildGaugeUrl(`/orgs/${safeUrlPathSegment(params.org, 'org')}/tags`), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTagList + return { + success: true, + output: { + tags: (data.data ?? []).map((item) => ({ + id: item.id, + name: item.name, + color: item.color, + promptCount: item.promptCount, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + tags: { + type: 'array', + items: { type: 'object', properties: TAG_PROPERTIES }, + description: 'Tags', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/list_topics.ts b/apps/sim/tools/gauge/list_topics.ts new file mode 100644 index 00000000000..934476fd940 --- /dev/null +++ b/apps/sim/tools/gauge/list_topics.ts @@ -0,0 +1,82 @@ +import { TOPIC_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeListTopicsParams, + GaugeListTopicsResponse, + GaugeTopicList, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeListTopicsTool: ToolConfig = { + id: 'gauge_list_topics', + name: 'Gauge List Topics', + description: 'List one page of tracked-prompt topics in a category.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque nextCursor from the previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return (1–200; defaults to 50)', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/topics`, + { cursor: params.cursor, limit: params.limit } + ), + method: 'GET', + headers: (params) => gaugeHeaders(params.accessToken, false), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTopicList + return { + success: true, + output: { + topics: (data.data ?? []).map((item) => ({ + id: item.id, + slug: item.slug, + title: item.title, + promptCount: item.promptCount, + createdAt: item.createdAt, + })), + nextCursor: data.nextCursor ?? null, + }, + } + }, + outputs: { + topics: { + type: 'array', + items: { type: 'object', properties: TOPIC_PROPERTIES }, + description: 'Topics', + }, + nextCursor: { + type: 'string', + description: 'Opaque cursor for the next page; null when complete', + nullable: true, + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gauge/outputs.ts b/apps/sim/tools/gauge/outputs.ts new file mode 100644 index 00000000000..63cb81ce3ba --- /dev/null +++ b/apps/sim/tools/gauge/outputs.ts @@ -0,0 +1,165 @@ +import type { OutputProperty } from '@/tools/types' + +export const PROMPT_OUT_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + text: { type: 'string', description: 'Text' }, + topicId: { type: 'string', description: 'Topic id', nullable: true }, + trackingType: { type: 'string', description: 'Tracking type' }, + tagIds: { type: 'array', items: { type: 'string' }, description: 'Tag ids' }, + createdAt: { type: 'string', description: 'Created at' }, + firstAnswerDay: { type: 'string', description: 'First answer day', nullable: true }, +} as const satisfies Record + +export const CATEGORY_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + slug: { type: 'string', description: 'Slug' }, + name: { type: 'string', description: 'Name' }, + description: { type: 'string', description: 'Description' }, +} as const satisfies Record + +const ORGANIZATION_PRODUCTS_PROPERTIES = { + chat: { type: 'boolean', description: 'Chat' }, + agents: { type: 'boolean', description: 'Agents', nullable: true }, +} as const satisfies Record + +export const FRESHNESS_OUT_PROPERTIES = { + watermark: { type: 'string', description: 'Watermark', nullable: true }, + pending: { type: 'number', description: 'Pending' }, + available: { type: 'boolean', description: 'Available' }, +} as const satisfies Record + +export const DIRECTORY_ORGANIZATION_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + slug: { type: 'string', description: 'Slug' }, + name: { type: 'string', description: 'Name' }, + products: { type: 'json', properties: ORGANIZATION_PRODUCTS_PROPERTIES, description: 'Products' }, +} as const satisfies Record + +const MENTION_PROPERTIES = { + brandId: { type: 'string', description: 'Brand id' }, + name: { type: 'string', description: 'Name' }, + mentionTypes: { type: 'array', items: { type: 'string' }, description: 'Mention types' }, + count: { type: 'number', description: 'Count' }, +} as const satisfies Record + +const ORGANIZATION_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + slug: { type: 'string', description: 'Slug' }, + name: { type: 'string', description: 'Name' }, +} as const satisfies Record + +export const API_KEY_INFO_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + name: { type: 'string', description: 'Name' }, + prefix: { type: 'string', description: 'Prefix' }, + organization: { type: 'json', properties: ORGANIZATION_PROPERTIES, description: 'Organization' }, +} as const satisfies Record + +export const COLUMN_PROPERTIES = { + key: { type: 'string', description: 'Key' }, + kind: { type: 'string', description: 'Kind' }, + type: { type: 'string', description: 'Type' }, + unit: { type: 'string', description: 'Unit', nullable: true, optional: true }, +} as const satisfies Record + +export const ANSWER_ITEM_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + promptId: { type: 'string', description: 'Prompt id' }, + topicId: { type: 'string', description: 'Topic id', nullable: true }, + model: { type: 'string', description: 'Model' }, + day: { type: 'string', description: 'Day' }, + createdAt: { type: 'string', description: 'Created at' }, + text: { type: 'string', description: 'Text' }, + brandIds: { type: 'array', items: { type: 'string' }, description: 'Brand ids' }, +} as const satisfies Record + +const CITED_PAGE_PROPERTIES = { + pageId: { type: 'string', description: 'Page id' }, + url: { type: 'string', description: 'Url' }, + title: { type: 'string', description: 'Title', nullable: true }, + domain: { type: 'string', description: 'Domain' }, +} as const satisfies Record + +export const ANSWER_DETAIL_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + promptId: { type: 'string', description: 'Prompt id' }, + promptText: { type: 'string', description: 'Prompt text' }, + topicId: { type: 'string', description: 'Topic id', nullable: true }, + model: { type: 'string', description: 'Model' }, + day: { type: 'string', description: 'Day' }, + createdAt: { type: 'string', description: 'Created at' }, + text: { type: 'string', description: 'Text' }, + mentions: { + type: 'array', + items: { type: 'object', properties: MENTION_PROPERTIES }, + description: 'Mentions', + }, + citations: { + type: 'array', + items: { type: 'object', properties: CITED_PAGE_PROPERTIES }, + description: 'Citations', + }, + searchQueries: { type: 'array', items: { type: 'string' }, description: 'Search queries' }, +} as const satisfies Record + +export const CITATION_ITEM_PROPERTIES = { + answerId: { type: 'string', description: 'Answer id' }, + pageId: { type: 'string', description: 'Page id' }, + promptId: { type: 'string', description: 'Prompt id' }, + model: { type: 'string', description: 'Model' }, + day: { type: 'string', description: 'Day' }, + domain: { type: 'string', description: 'Domain' }, + url: { type: 'string', description: 'Url' }, +} as const satisfies Record + +export const TAG_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + name: { type: 'string', description: 'Name' }, + color: { type: 'string', description: 'Color' }, + promptCount: { type: 'number', description: 'Prompt count' }, +} as const satisfies Record + +export const CREATE_RESULT_PROPERTIES = { + index: { type: 'number', description: 'Index' }, + status: { type: 'string', description: 'Status' }, + prompt: { + type: 'json', + properties: PROMPT_OUT_PROPERTIES, + description: 'Prompt', + nullable: true, + optional: true, + }, +} as const satisfies Record + +export const TOKEN_INFO_PROPERTIES = { + kind: { type: 'string', description: 'Kind' }, + scopes: { type: 'array', items: { type: 'string' }, description: 'Scopes' }, + expiresAt: { type: 'string', description: 'Expires at', nullable: true }, +} as const satisfies Record + +export const DATE_RANGE_OUT_PROPERTIES = { + since: { type: 'string', description: 'Since' }, + until: { type: 'string', description: 'Until' }, +} as const satisfies Record + +export const BRAND_OUT_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + name: { type: 'string', description: 'Name' }, + slug: { type: 'string', description: 'Slug' }, + website: { type: 'string', description: 'Website' }, + role: { type: 'string', description: 'Role' }, +} as const satisfies Record + +export const USER_INFO_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + email: { type: 'string', description: 'Email' }, + name: { type: 'string', description: 'Name' }, +} as const satisfies Record + +export const TOPIC_PROPERTIES = { + id: { type: 'string', description: 'Id' }, + slug: { type: 'string', description: 'Slug' }, + title: { type: 'string', description: 'Title' }, + promptCount: { type: 'number', description: 'Prompt count' }, + createdAt: { type: 'string', description: 'Created at' }, +} as const satisfies Record diff --git a/apps/sim/tools/gauge/query.ts b/apps/sim/tools/gauge/query.ts new file mode 100644 index 00000000000..47c67ceba2a --- /dev/null +++ b/apps/sim/tools/gauge/query.ts @@ -0,0 +1,161 @@ +import { isRecordLike } from '@sim/utils/object' +import { + COLUMN_PROPERTIES, + DATE_RANGE_OUT_PROPERTIES, + FRESHNESS_OUT_PROPERTIES, +} from '@/tools/gauge/outputs' +import type { GaugeQueryOut, GaugeQueryParams, GaugeQueryResponse } from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeJson, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +function parseObject(value: unknown, name: string): Record | undefined { + const parsed = parseGaugeJson(value, name) + if (parsed == null) return undefined + if (!isRecordLike(parsed)) throw new Error(`${name} must be a JSON object`) + return parsed +} + +function parseArray(value: unknown, name: string): unknown[] | undefined { + const parsed = parseGaugeJson(value, name) + if (parsed == null) return undefined + if (!Array.isArray(parsed)) throw new Error(`${name} must be a JSON array`) + return parsed +} +export const gaugeQueryTool: ToolConfig = { + id: 'gauge_query', + name: 'Gauge Run Report Query', + description: + 'Run a report on AI answers, citations, search queries, or AI bot traffic using metrics from the query catalog.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + dataset: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Report dataset: answers, citations, searchQueries, or aiTraffic', + }, + dimensions: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of up to 3 dimension keys from Get Query Metadata', + }, + metrics: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of 1–4 metric keys from Get Query Metadata', + }, + filters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of category, brand, model, topic, tag, tagMode, prompt, trackingType, domain, and page filters', + }, + where: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of up to 10 predicates: {"dimension":"domain","op":"contains","value":"example.com"}', + }, + dateRange: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object with since (YYYY-MM-DD or 30d), until (YYYY-MM-DD), or lastDays (1–400)', + }, + granularity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Report time bucket: hour, day, week, or month (defaults to day)', + }, + orderBy: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of up to 4 sort rules: {"key":"metric_name","dir":"desc"}', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum report rows (1–1000; defaults to 50)', + }, + }, + request: { + url: (params) => buildGaugeUrl(`/orgs/${safeUrlPathSegment(params.org, 'org')}/query`), + method: 'POST', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ + dataset: params.dataset, + dimensions: + params.dimensions == null || params.dimensions === '' + ? undefined + : parseGaugeStringArray(params.dimensions, 'dimensions', { max: 3 }), + metrics: parseGaugeStringArray(params.metrics, 'metrics', { min: 1, max: 4 }), + filters: parseObject(params.filters, 'filters'), + where: parseArray(params.where, 'where'), + dateRange: parseObject(params.dateRange, 'dateRange'), + granularity: params.granularity, + orderBy: parseArray(params.orderBy, 'orderBy'), + limit: params.limit, + }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeQueryOut + return { + success: true, + output: { + columns: (data.columns ?? []).map((item) => ({ + key: item.key, + kind: item.kind, + type: item.type, + unit: item.unit ?? null, + })), + rows: data.rows ?? [], + labels: data.labels ?? {}, + dateRange: { since: data.dateRange.since, until: data.dateRange.until }, + truncated: data.truncated, + cached: data.cached, + freshness: { + watermark: data.freshness.watermark ?? null, + pending: data.freshness.pending, + available: data.freshness.available, + }, + }, + } + }, + outputs: { + columns: { + type: 'array', + items: { type: 'object', properties: COLUMN_PROPERTIES }, + description: 'Columns', + }, + rows: { type: 'array', items: { type: 'json' }, description: 'Rows' }, + labels: { type: 'json', description: 'Labels' }, + dateRange: { type: 'json', properties: DATE_RANGE_OUT_PROPERTIES, description: 'Date range' }, + truncated: { type: 'boolean', description: 'Truncated' }, + cached: { type: 'boolean', description: 'Cached' }, + freshness: { type: 'json', properties: FRESHNESS_OUT_PROPERTIES, description: 'Freshness' }, + }, +} diff --git a/apps/sim/tools/gauge/remove_tags_from_prompts.ts b/apps/sim/tools/gauge/remove_tags_from_prompts.ts new file mode 100644 index 00000000000..6d43b279f73 --- /dev/null +++ b/apps/sim/tools/gauge/remove_tags_from_prompts.ts @@ -0,0 +1,71 @@ +import type { + GaugePromptTagsOut, + GaugeRemoveTagsFromPromptsParams, + GaugeRemoveTagsFromPromptsResponse, +} from '@/tools/gauge/types' +import { + buildGaugeUrl, + gaugeAuthParams, + gaugeHeaders, + gaugeOAuth, + parseGaugeStringArray, +} from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeRemoveTagsFromPromptsTool: ToolConfig< + GaugeRemoveTagsFromPromptsParams, + GaugeRemoveTagsFromPromptsResponse +> = { + id: 'gauge_remove_tags_from_prompts', + name: 'Gauge Remove Tags From Prompts', + description: 'Remove a set of tags from the selected tracked prompts.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + promptIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of 1–500 prompt IDs', + }, + tagIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of 1–50 tag IDs', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts/tags` + ), + method: 'DELETE', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ + promptIds: parseGaugeStringArray(params.promptIds, 'promptIds', { min: 1, max: 500 }), + tagIds: parseGaugeStringArray(params.tagIds, 'tagIds', { min: 1, max: 50 }), + }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugePromptTagsOut + return { success: true, output: { promptIds: data.promptIds ?? [] } } + }, + outputs: { + promptIds: { type: 'array', items: { type: 'string' }, description: 'Prompt ids' }, + }, +} diff --git a/apps/sim/tools/gauge/rename_topic.ts b/apps/sim/tools/gauge/rename_topic.ts new file mode 100644 index 00000000000..be80b6d1c26 --- /dev/null +++ b/apps/sim/tools/gauge/rename_topic.ts @@ -0,0 +1,66 @@ +import { TOPIC_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugeRenameTopicParams, + GaugeRenameTopicResponse, + GaugeTopic, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeRenameTopicTool: ToolConfig = { + id: 'gauge_rename_topic', + name: 'Gauge Rename Topic', + description: 'Change the title of an existing topic.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + topicId: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Topic ID' }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Topic title (1–200 characters)', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/topics/${safeUrlPathSegment(params.topicId, 'topicId')}` + ), + method: 'PATCH', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ title: params.title }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTopic + return { + success: true, + output: { + topic: { + id: data.id, + slug: data.slug, + title: data.title, + promptCount: data.promptCount, + createdAt: data.createdAt, + }, + }, + } + }, + outputs: { + topic: { type: 'json', properties: TOPIC_PROPERTIES, description: 'Topic' }, + }, +} diff --git a/apps/sim/tools/gauge/types.ts b/apps/sim/tools/gauge/types.ts new file mode 100644 index 00000000000..583b9413162 --- /dev/null +++ b/apps/sim/tools/gauge/types.ts @@ -0,0 +1,599 @@ +import type { ToolResponse } from '@/tools/types' + +interface GaugeAuthParams { + accessToken: string +} + +interface GaugeAPIKeyInfo { + id: string + name: string + prefix: string + organization: GaugeOrganization +} + +export interface GaugeMe { + user: GaugeUserInfo | null + apiKey: GaugeAPIKeyInfo | null + token: GaugeTokenInfo +} + +interface GaugeOrganization { + id: string + slug: string + name: string +} + +interface GaugeTokenInfo { + kind: 'session' | 'api_key' + scopes: string[] + expiresAt: string | null +} + +interface GaugeUserInfo { + id: string + email: string + name: string +} + +export interface GaugeDirectoryOrganization { + id: string + slug: string + name: string + products: GaugeOrganizationProducts +} + +export interface GaugeOrganizationList { + data: GaugeDirectoryOrganization[] + nextCursor?: string | null +} + +interface GaugeOrganizationProducts { + chat: boolean + agents: boolean | null +} + +export interface GaugeCategory { + id: string + slug: string + name: string + description: string +} + +export interface GaugeCategoryList { + data: GaugeCategory[] + nextCursor?: string | null +} + +export interface GaugeTopic { + id: string + slug: string + title: string + promptCount: number + createdAt: string +} + +export interface GaugeTopicList { + data: GaugeTopic[] + nextCursor?: string | null +} + +export interface GaugePromptList { + data: GaugePromptOut[] + nextCursor?: string | null +} + +export interface GaugePromptOut { + id: string + text: string + topicId: string | null + trackingType: string + tagIds: string[] + createdAt: string + firstAnswerDay: string | null +} + +export interface GaugeCreatePromptsOut { + results: GaugeCreateResult[] + dryRun: boolean + remaining: number | null +} + +interface GaugeCreateResult { + index: number + status: 'created' | 'already_exists' | 'would_create' + prompt?: GaugePromptOut | null +} + +export interface GaugePromptIn { + text: string + topicId: string + trackingType?: 'visibility' | 'branded_own' + tagIds?: string[] +} + +export interface GaugeBulkPatchOut { + updated: string[] + notFound: string[] +} + +export interface GaugePromptTagsOut { + promptIds: string[] +} + +export interface GaugeTag { + id: string + name: string + color: string + promptCount: number +} + +export interface GaugeTagList { + data: GaugeTag[] + nextCursor?: string | null +} + +export interface GaugeBrandList { + data: GaugeBrandOut[] + nextCursor?: string | null +} + +interface GaugeBrandOut { + id: string + name: string + slug: string + website: string + role: 'owned' | 'competitor' +} + +interface GaugeColumn { + key: string + kind: 'dimension' | 'metric' + type: string + unit?: string | null +} + +interface GaugeDateRangeOut { + since: string + until: string +} + +interface GaugeFreshnessOut { + watermark: string | null + pending: number + available: boolean +} + +export interface GaugeQueryOut { + columns: GaugeColumn[] + rows: Record[] + labels: Record>> + dateRange: GaugeDateRangeOut + truncated: boolean + cached: boolean + freshness: GaugeFreshnessOut +} + +interface GaugeAnswerItem { + id: string + promptId: string + topicId: string | null + model: string + day: string + createdAt: string + text: string + brandIds: string[] +} + +export interface GaugeAnswerList { + data: GaugeAnswerItem[] + nextCursor?: string | null +} + +export interface GaugeAnswerDetail { + id: string + promptId: string + promptText: string + topicId: string | null + model: string + day: string + createdAt: string + text: string + mentions: GaugeMention[] + citations: GaugeCitedPage[] + searchQueries: string[] +} + +interface GaugeCitedPage { + pageId: string + url: string + title: string | null + domain: string +} + +interface GaugeMention { + brandId: string + name: string + mentionTypes: string[] + count: number +} + +interface GaugeCitationItem { + answerId: string + pageId: string + promptId: string + model: string + day: string + domain: string + url: string +} + +export interface GaugeCitationList { + data: GaugeCitationItem[] + nextCursor?: string | null +} + +export interface GaugeGetCurrentCredentialParams extends GaugeAuthParams {} + +export interface GaugeGetCurrentCredentialResponse extends ToolResponse { + output: { + user: GaugeUserInfo | null + apiKey: GaugeAPIKeyInfo | null + token: GaugeTokenInfo + } +} + +export interface GaugeListOrganizationsParams extends GaugeAuthParams {} + +export interface GaugeListOrganizationsResponse extends ToolResponse { + output: { + organizations: GaugeDirectoryOrganization[] + nextCursor: string | null + } +} + +export interface GaugeGetOrganizationParams extends GaugeAuthParams { + org: string +} + +export interface GaugeGetOrganizationResponse extends ToolResponse { + output: { + organization: GaugeDirectoryOrganization + } +} + +export interface GaugeListCategoriesParams extends GaugeAuthParams { + org: string +} + +export interface GaugeListCategoriesResponse extends ToolResponse { + output: { + categories: GaugeCategory[] + nextCursor: string | null + } +} + +export interface GaugeGetCategoryParams extends GaugeAuthParams { + org: string + category: string +} + +export interface GaugeGetCategoryResponse extends ToolResponse { + output: { + category: GaugeCategory + } +} + +export interface GaugeListTopicsParams extends GaugeAuthParams { + org: string + category: string + cursor?: string | null + limit?: number +} + +export interface GaugeListTopicsResponse extends ToolResponse { + output: { + topics: GaugeTopic[] + nextCursor: string | null + } +} + +export interface GaugeCreateTopicParams extends GaugeAuthParams { + org: string + category: string + title: string +} + +export interface GaugeCreateTopicResponse extends ToolResponse { + output: { + topic: GaugeTopic + } +} + +export interface GaugeGetTopicParams extends GaugeAuthParams { + org: string + category: string + topicId: string +} + +export interface GaugeGetTopicResponse extends ToolResponse { + output: { + topic: GaugeTopic + } +} + +export interface GaugeRenameTopicParams extends GaugeAuthParams { + org: string + category: string + topicId: string + title: string +} + +export interface GaugeRenameTopicResponse extends ToolResponse { + output: { + topic: GaugeTopic + } +} + +export interface GaugeListPromptsParams extends GaugeAuthParams { + org: string + category: string + topic?: string | null + tag?: unknown + trackingType?: 'visibility' | 'branded_own' | 'all' + cursor?: string | null + limit?: number +} + +export interface GaugeListPromptsResponse extends ToolResponse { + output: { + prompts: GaugePromptOut[] + nextCursor: string | null + } +} + +export interface GaugeCreatePromptsParams extends GaugeAuthParams { + org: string + category: string + dryRun?: boolean + prompts: unknown +} + +export interface GaugeCreatePromptsResponse extends ToolResponse { + output: { + results: GaugeCreateResult[] + dryRun: boolean + remaining: number | null + } +} + +export interface GaugeBulkUpdatePromptsParams extends GaugeAuthParams { + org: string + category: string + topicId?: string | null + trackingType?: 'visibility' | 'branded_own' | null + ids: unknown +} + +export interface GaugeBulkUpdatePromptsResponse extends ToolResponse { + output: { + updated: string[] + notFound: string[] + } +} + +export interface GaugeAddTagsToPromptsParams extends GaugeAuthParams { + org: string + category: string + promptIds: unknown + tagIds: unknown +} + +export interface GaugeAddTagsToPromptsResponse extends ToolResponse { + output: { + promptIds: string[] + } +} + +export interface GaugeRemoveTagsFromPromptsParams extends GaugeAuthParams { + org: string + category: string + promptIds: unknown + tagIds: unknown +} + +export interface GaugeRemoveTagsFromPromptsResponse extends ToolResponse { + output: { + promptIds: string[] + } +} + +export interface GaugeGetPromptParams extends GaugeAuthParams { + org: string + category: string + promptId: string +} + +export interface GaugeGetPromptResponse extends ToolResponse { + output: { + prompt: GaugePromptOut + } +} + +export interface GaugeUpdatePromptParams extends GaugeAuthParams { + org: string + category: string + promptId: string + topicId?: string | null + trackingType?: 'visibility' | 'branded_own' | null +} + +export interface GaugeUpdatePromptResponse extends ToolResponse { + output: { + prompt: GaugePromptOut + } +} + +export interface GaugeListTagsParams extends GaugeAuthParams { + org: string +} + +export interface GaugeListTagsResponse extends ToolResponse { + output: { + tags: GaugeTag[] + nextCursor: string | null + } +} + +export interface GaugeCreateTagParams extends GaugeAuthParams { + org: string + name: string + color?: string +} + +export interface GaugeCreateTagResponse extends ToolResponse { + output: { + tag: GaugeTag + } +} + +export interface GaugeGetTagParams extends GaugeAuthParams { + org: string + tagId: string +} + +export interface GaugeGetTagResponse extends ToolResponse { + output: { + tag: GaugeTag + } +} + +export interface GaugeUpdateTagParams extends GaugeAuthParams { + org: string + tagId: string + name?: string | null + color?: string | null +} + +export interface GaugeUpdateTagResponse extends ToolResponse { + output: { + tag: GaugeTag + } +} + +export interface GaugeDeleteTagParams extends GaugeAuthParams { + org: string + tagId: string +} + +export interface GaugeDeleteTagResponse extends ToolResponse { + output: { + tagId: string + deleted: boolean + } +} + +export interface GaugeListBrandsParams extends GaugeAuthParams { + org: string + category: string +} + +export interface GaugeListBrandsResponse extends ToolResponse { + output: { + brands: GaugeBrandOut[] + nextCursor: string | null + } +} + +export interface GaugeQueryParams extends GaugeAuthParams { + org: string + dataset: 'answers' | 'citations' | 'searchQueries' | 'aiTraffic' + dimensions?: unknown + metrics: unknown + filters?: unknown + where?: unknown + dateRange?: unknown + granularity?: 'hour' | 'day' | 'week' | 'month' + orderBy?: unknown + limit?: number +} + +export interface GaugeQueryResponse extends ToolResponse { + output: { + columns: GaugeColumn[] + rows: Record[] + labels: Record>> + dateRange: GaugeDateRangeOut + truncated: boolean + cached: boolean + freshness: GaugeFreshnessOut + } +} + +export interface GaugeGetQueryMetadataParams extends GaugeAuthParams { + org: string + dataset?: string | null +} + +export interface GaugeGetQueryMetadataResponse extends ToolResponse { + output: { + catalog: Record + } +} + +export interface GaugeListAnswersParams extends GaugeAuthParams { + org: string + category: string + since?: string | null + until?: string | null + model?: unknown + prompt?: unknown + topic?: unknown + tag?: unknown + trackingType?: 'visibility' | 'branded_own' | 'all' + cursor?: string | null + limit?: number + brand?: unknown + brandAbsent?: unknown +} + +export interface GaugeListAnswersResponse extends ToolResponse { + output: { + answers: GaugeAnswerItem[] + nextCursor: string | null + } +} + +export interface GaugeGetAnswerParams extends GaugeAuthParams { + org: string + category: string + answerId: string +} + +export interface GaugeGetAnswerResponse extends ToolResponse { + output: { + answer: GaugeAnswerDetail + } +} + +export interface GaugeListCitationsParams extends GaugeAuthParams { + org: string + category: string + since?: string | null + until?: string | null + model?: unknown + prompt?: unknown + topic?: unknown + tag?: unknown + trackingType?: 'visibility' | 'branded_own' | 'all' + cursor?: string | null + limit?: number + domain?: unknown + page?: unknown +} + +export interface GaugeListCitationsResponse extends ToolResponse { + output: { + citations: GaugeCitationItem[] + nextCursor: string | null + } +} diff --git a/apps/sim/tools/gauge/update_prompt.ts b/apps/sim/tools/gauge/update_prompt.ts new file mode 100644 index 00000000000..6f43de5acbb --- /dev/null +++ b/apps/sim/tools/gauge/update_prompt.ts @@ -0,0 +1,81 @@ +import { PROMPT_OUT_PROPERTIES } from '@/tools/gauge/outputs' +import type { + GaugePromptOut, + GaugeUpdatePromptParams, + GaugeUpdatePromptResponse, +} from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeUpdatePromptTool: ToolConfig = + { + id: 'gauge_update_prompt', + name: 'Gauge Update Prompt', + description: + 'Move a prompt to another topic or change its tracking type. Prompt text is immutable: create a new prompt instead.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + category: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category slug or cat_ ID', + }, + promptId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Prompt ID', + }, + topicId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New topic ID; omitted values leave the topic unchanged', + }, + trackingType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt tracking type: visibility or branded_own', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/categories/${safeUrlPathSegment(params.category, 'category')}/prompts/${safeUrlPathSegment(params.promptId, 'promptId')}` + ), + method: 'PATCH', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ topicId: params.topicId, trackingType: params.trackingType }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugePromptOut + return { + success: true, + output: { + prompt: { + id: data.id, + text: data.text, + topicId: data.topicId ?? null, + trackingType: data.trackingType, + tagIds: data.tagIds ?? [], + createdAt: data.createdAt, + firstAnswerDay: data.firstAnswerDay ?? null, + }, + }, + } + }, + outputs: { + prompt: { type: 'json', properties: PROMPT_OUT_PROPERTIES, description: 'Prompt' }, + }, + } diff --git a/apps/sim/tools/gauge/update_tag.ts b/apps/sim/tools/gauge/update_tag.ts new file mode 100644 index 00000000000..c120dfd2e88 --- /dev/null +++ b/apps/sim/tools/gauge/update_tag.ts @@ -0,0 +1,57 @@ +import { TAG_PROPERTIES } from '@/tools/gauge/outputs' +import type { GaugeTag, GaugeUpdateTagParams, GaugeUpdateTagResponse } from '@/tools/gauge/types' +import { buildGaugeUrl, gaugeAuthParams, gaugeHeaders, gaugeOAuth } from '@/tools/gauge/utils' +import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const gaugeUpdateTagTool: ToolConfig = { + id: 'gauge_update_tag', + name: 'Gauge Update Tag', + description: 'Change the name or color of a prompt tag.', + version: '1.0.0', + oauth: gaugeOAuth, + params: { + ...gaugeAuthParams, + org: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Organization slug or UUID', + }, + tagId: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Tag ID' }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag name (1–120 characters)', + }, + color: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Tag color string (up to 20 characters); omit to keep unchanged or use "" to clear', + }, + }, + request: { + url: (params) => + buildGaugeUrl( + `/orgs/${safeUrlPathSegment(params.org, 'org')}/tags/${safeUrlPathSegment(params.tagId, 'tagId')}` + ), + method: 'PATCH', + headers: (params) => gaugeHeaders(params.accessToken, true), + body: (params) => ({ name: params.name, color: params.color }), + }, + transformResponse: async (response) => { + const data = (await response.json()) as GaugeTag + return { + success: true, + output: { + tag: { id: data.id, name: data.name, color: data.color, promptCount: data.promptCount }, + }, + } + }, + outputs: { + tag: { type: 'json', properties: TAG_PROPERTIES, description: 'Tag' }, + }, +} diff --git a/apps/sim/tools/gauge/utils.test.ts b/apps/sim/tools/gauge/utils.test.ts new file mode 100644 index 00000000000..ecae98fffb4 --- /dev/null +++ b/apps/sim/tools/gauge/utils.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { gaugeCreatePromptsTool } from '@/tools/gauge/create_prompts' +import { buildGaugeUrl, parseGaugeJson, parseGaugeStringArray } from '@/tools/gauge/utils' + +describe('Gauge request encoding', () => { + it('preserves each array filter as a separate encoded query value', () => { + const url = new URL( + buildGaugeUrl('/orgs/example/query/metadata', { + model: ['gpt-4.1', 'a&topic=other'], + dryRun: false, + limit: 0, + cursor: undefined, + }) + ) + expect(url.searchParams.getAll('model')).toEqual(['gpt-4.1', 'a&topic=other']) + expect(url.searchParams.has('topic')).toBe(false) + expect(url.searchParams.get('dryRun')).toBe('false') + expect(url.searchParams.get('limit')).toBe('0') + expect(url.searchParams.has('cursor')).toBe(false) + }) + + it('preserves explicit JSON null while omitting blank optional JSON', () => { + expect(parseGaugeJson('null', 'filters')).toBeNull() + expect(parseGaugeJson(null, 'filters')).toBeNull() + expect(parseGaugeJson(' ', 'filters')).toBeUndefined() + expect(() => parseGaugeJson('{broken', 'filters')).toThrow('filters must be valid JSON') + }) + + it('rejects scalar and mixed-type array inputs before constructing provider requests', () => { + expect(() => parseGaugeStringArray('"one"', 'tag')).toThrow() + expect(() => parseGaugeStringArray('["one", 2]', 'tag')).toThrow() + expect(parseGaugeStringArray('["one", "two"]', 'tag')).toEqual(['one', 'two']) + expect(() => parseGaugeStringArray([], 'ids', { min: 1, max: 500 })).toThrow() + expect(() => parseGaugeStringArray(['a', 'b'], 'tagIds', { max: 1 })).toThrow() + }) + + it.each(['', ' '])('rejects a blank identifier %j instead of sending it to Gauge', (value) => { + expect(() => parseGaugeStringArray([value], 'ids', { min: 1 })).toThrow() + expect(parseGaugeStringArray([' padded-but-nonblank '], 'ids')).toEqual([ + ' padded-but-nonblank ', + ]) + }) +}) + +const modelInput = gaugeCreatePromptsTool.request.modelInput +const body = gaugeCreatePromptsTool.request.body +if (!modelInput || modelInput.mode !== 'project' || !modelInput.applyProjected || !body) { + throw new Error('Gauge prompt request formatting is unavailable') +} +const { select, applyProjected } = modelInput +const requestParams = { accessToken: 'gauge_sk_example', org: 'example', category: 'example' } + +describe('Gauge tracked prompt model input', () => { + const prompts = [ + { + text: 'Which analytics tool works with token-value?', + topicId: 'top_example', + tagIds: ['tag_example'], + trackingType: 'visibility' as const, + }, + { text: 'Compare analytics tools', topicId: 'top_other' }, + ] + + it.each([ + ['surrogate pairs', '😀'.repeat(500)], + ['mixed Unicode and ASCII', `${'😀'.repeat(250)}${'a'.repeat(250)}`], + ])('accepts 500 code points containing %s without changing the text', (_label, text) => { + expect(body({ ...requestParams, prompts: [{ text, topicId: 'top_example' }] })).toMatchObject({ + prompts: [{ text, topicId: 'top_example' }], + }) + }) + + it.each([ + ['mixed Unicode and ASCII', `${'😀'.repeat(251)}${'a'.repeat(250)}`], + ['ASCII', 'a'.repeat(501)], + ['surrogate pairs', '😀'.repeat(501)], + ])('rejects 501 code points containing %s', (_label, text) => { + expect(() => body({ ...requestParams, prompts: [{ text, topicId: 'top_example' }] })).toThrow( + 'Each prompt must have text (1 to 500 characters) and a topicId' + ) + }) + + it('selects prompt text and preserves control fields when rebuilding projected JSON', () => { + const original = JSON.stringify(prompts) + expect(select({ ...requestParams, prompts: original })).toEqual({ + prompts: ['Which analytics tool works with token-value?', 'Compare analytics tools'], + }) + const rebuilt = applyProjected( + { prompts: original }, + { prompts: ['Which analytics tool works with {{TOKEN}}?', 'Compare analytics tools'] } + ).prompts + expect(typeof rebuilt).toBe('string') + expect(body({ ...requestParams, prompts: rebuilt })).toEqual({ + prompts: [{ ...prompts[0], text: 'Which analytics tool works with {{TOKEN}}?' }, prompts[1]], + }) + expect(select({ ...requestParams, prompts: rebuilt })).toEqual({ + prompts: ['Which analytics tool works with {{TOKEN}}?', 'Compare analytics tools'], + }) + expect(prompts[0].text).toContain('token-value') + }) + + it('preserves array input shape and rejects mismatched projections', () => { + const rebuilt = applyProjected( + { prompts }, + { prompts: ['{{TOKEN}}', 'Compare analytics tools'] } + ).prompts + expect(Array.isArray(rebuilt)).toBe(true) + expect(body({ ...requestParams, prompts: rebuilt })).toEqual({ + prompts: [{ ...prompts[0], text: '{{TOKEN}}' }, prompts[1]], + }) + expect(() => applyProjected({ prompts }, { prompts: ['only one'] })).toThrow() + expect(() => + applyProjected({ prompts }, { prompts: [123, 'Compare analytics tools'] }) + ).toThrow() + }) + + it.each([ + '{broken', + 'null', + '{}', + '[]', + '[{"text":"hello"}]', + '[{"text":10,"topicId":"top_example"}]', + '[{"text":"hello","topicId":"top_example","tagIds":[5]}]', + ])('rejects malformed prompt batches: %s', (input) => { + expect(() => body({ ...requestParams, prompts: input })).toThrow() + }) + + it('rejects batches beyond the documented provider cap', () => { + expect(() => + body({ ...requestParams, prompts: Array.from({ length: 501 }, () => prompts[0]) }) + ).toThrow() + }) +}) diff --git a/apps/sim/tools/gauge/utils.ts b/apps/sim/tools/gauge/utils.ts new file mode 100644 index 00000000000..58fcbdd3c9a --- /dev/null +++ b/apps/sim/tools/gauge/utils.ts @@ -0,0 +1,73 @@ +import type { ToolConfig } from '@/tools/types' + +export const gaugeAuthParams = { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Gauge organization API key resolved from the selected credential', + }, +} as const satisfies ToolConfig['params'] + +export const gaugeOAuth = { + required: true, + provider: 'gauge', + credentialKind: 'service-account', +} as const + +/** Builds a provider URL while retaining repeated query keys for array filters. */ +export function buildGaugeUrl( + path: string, + query: Record = {} +): string { + const url = new URL(`https://app.withgauge.com/api/v1${path}`) + for (const [name, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value)) { + for (const item of value) url.searchParams.append(name, item) + } else { + url.searchParams.set(name, String(value)) + } + } + return url.toString() +} + +/** Gauge accepts JSON request bodies and organization API keys as bearer tokens. */ +export function gaugeHeaders(accessToken: string, hasBody = false): Record { + return { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + } +} + +/** Parses block JSON inputs without conflating an explicit null with an omitted value. */ +export function parseGaugeJson(value: unknown, name: string): unknown { + if (typeof value !== 'string') return value + if (!value.trim()) return undefined + try { + return JSON.parse(value) + } catch { + throw new Error(`${name} must be valid JSON`) + } +} + +/** Validates nonblank string arrays without changing the supplied values. */ +export function parseGaugeStringArray( + value: unknown, + name: string, + bounds: { min?: number; max?: number } = {} +): string[] { + const parsed = parseGaugeJson(value, name) + if (parsed == null && !bounds.min) return [] + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string' || !item.trim())) { + throw new Error(`${name} must be a JSON array of non-empty strings`) + } + if ( + (bounds.min !== undefined && parsed.length < bounds.min) || + (bounds.max !== undefined && parsed.length > bounds.max) + ) { + throw new Error(`${name} must contain ${bounds.min ?? 0} to ${bounds.max ?? 'unlimited'} items`) + } + return parsed +} diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index 4a1ee4d37fc..3b116015474 100644 --- a/apps/sim/tools/generated/tool-ids.ts +++ b/apps/sim/tools/generated/tool-ids.ts @@ -3,7 +3,7 @@ /** Every registered tool id, including versioned variants. */ const toolIds: string[] = JSON.parse( - '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_aggregated_post_metrics","buffer_get_channel","buffer_get_channels","buffer_get_daily_posting_limits","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","checkr_add_report_tag","checkr_apply_report_review_action","checkr_cancel_adverse_action","checkr_cancel_continuous_check","checkr_cancel_invitation","checkr_cancel_subscription","checkr_complete_report","checkr_create_adverse_action","checkr_create_candidate","checkr_create_continuous_check","checkr_create_geo","checkr_create_invitation","checkr_create_report","checkr_create_subscription","checkr_delete_candidate_pii","checkr_delete_geo","checkr_get_account","checkr_get_adverse_action","checkr_get_candidate","checkr_get_continuous_check","checkr_get_document","checkr_get_geo","checkr_get_invitation","checkr_get_node","checkr_get_package","checkr_get_program","checkr_get_report","checkr_get_report_eta","checkr_get_report_progressive_status","checkr_get_report_tags","checkr_get_screening","checkr_get_subscription","checkr_get_verification","checkr_list_adverse_actions","checkr_list_adverse_items","checkr_list_assessments","checkr_list_candidate_documents","checkr_list_candidates","checkr_list_continuous_checks","checkr_list_counties","checkr_list_geos","checkr_list_invitations","checkr_list_nodes","checkr_list_packages","checkr_list_programs","checkr_list_report_addresses","checkr_list_subscriptions","checkr_list_users","checkr_list_verifications","checkr_remove_report_tag","checkr_set_report_tags","checkr_update_candidate","checkr_update_continuous_check","checkr_update_geo","checkr_update_report","checkr_update_subscription","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_group","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_tunnel","cloudflare_create_worker_route","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_group","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_r2_bucket_cors","cloudflare_delete_ruleset","cloudflare_delete_ruleset_rule","cloudflare_delete_tunnel","cloudflare_delete_worker_route","cloudflare_delete_worker_script","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_access_group","cloudflare_get_access_policy","cloudflare_get_access_service_token","cloudflare_get_dns_record","cloudflare_get_r2_bucket","cloudflare_get_r2_bucket_cors","cloudflare_get_r2_bucket_lifecycle","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_tunnel_token","cloudflare_get_worker_route","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_refresh_access_service_token","cloudflare_revoke_access_service_token","cloudflare_rotate_access_service_token","cloudflare_update_access_application","cloudflare_update_access_group","cloudflare_update_access_policy","cloudflare_update_access_service_token","cloudflare_update_dns_record","cloudflare_update_r2_bucket_cors","cloudflare_update_r2_bucket_lifecycle","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset","cloudflare_update_ruleset_rule","cloudflare_update_tunnel","cloudflare_update_tunnel_configuration","cloudflare_update_worker_route","cloudflare_update_zone","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_all_runs","databricks_cancel_run","databricks_cancel_statement","databricks_create_cluster","databricks_create_job","databricks_create_warehouse","databricks_delete_cluster","databricks_delete_job","databricks_delete_run","databricks_delete_warehouse","databricks_edit_cluster","databricks_edit_warehouse","databricks_execute_sql","databricks_genie_agent_ask","databricks_genie_agent_cancel","databricks_genie_agent_list_items","databricks_genie_ask","databricks_genie_create_space","databricks_genie_delete_conversation","databricks_genie_delete_message","databricks_genie_delete_space","databricks_genie_download_visualization","databricks_genie_execute_query","databricks_genie_get_message","databricks_genie_get_query_result","databricks_genie_get_space","databricks_genie_list_conversations","databricks_genie_list_messages","databricks_genie_list_spaces","databricks_genie_send_feedback","databricks_genie_update_space","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_get_statement_chunk","databricks_get_warehouse","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_repair_run","databricks_reset_job","databricks_resize_cluster","databricks_restart_cluster","databricks_run_job","databricks_start_cluster","databricks_start_warehouse","databricks_stop_warehouse","databricks_submit_run","databricks_terminate_cluster","databricks_update_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_download_v2","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_get_qr_code_v2","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","ec2_allocate_address","ec2_associate_address","ec2_authorize_security_group_ingress","ec2_create_snapshot","ec2_create_tags","ec2_delete_snapshot","ec2_delete_tags","ec2_delete_volume","ec2_describe_addresses","ec2_describe_availability_zones","ec2_describe_images","ec2_describe_instance_status","ec2_describe_instances","ec2_describe_internet_gateways","ec2_describe_key_pairs","ec2_describe_nat_gateways","ec2_describe_network_acls","ec2_describe_network_interfaces","ec2_describe_regions","ec2_describe_route_tables","ec2_describe_security_groups","ec2_describe_snapshots","ec2_describe_subnets","ec2_describe_volumes","ec2_describe_vpc_endpoints","ec2_describe_vpc_peering_connections","ec2_describe_vpcs","ec2_disassociate_address","ec2_reboot_instances","ec2_release_address","ec2_revoke_security_group_ingress","ec2_start_instances","ec2_stop_instances","ec2_terminate_instances","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","fivetran_cancel_transformation","fivetran_create_connect_card","fivetran_create_connection","fivetran_create_destination","fivetran_create_group","fivetran_delete_connection","fivetran_get_account_info","fivetran_get_connection","fivetran_get_connection_schema_config","fivetran_get_connection_warnings","fivetran_get_connector_type","fivetran_get_destination","fivetran_get_group","fivetran_get_sync_history","fivetran_get_transformation","fivetran_list_connections","fivetran_list_connector_types","fivetran_list_destinations","fivetran_list_groups","fivetran_list_transformations","fivetran_list_users","fivetran_reload_connection_schema","fivetran_resync_connection","fivetran_resync_tables","fivetran_run_connection_setup_tests","fivetran_run_destination_setup_tests","fivetran_run_transformation","fivetran_sync_connection","fivetran_update_connection","fivetran_update_connection_schema","fivetran_update_connection_table","fivetran_update_schema_config","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_respond","google_calendar_respond_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_apply_candidate_tag_v2","greenhouse_convert_prospect_v2","greenhouse_create_application_v2","greenhouse_create_attachment_v2","greenhouse_create_candidate_tag_v2","greenhouse_create_candidate_v2","greenhouse_create_note_v2","greenhouse_delete_application_v2","greenhouse_delete_attachment_v2","greenhouse_delete_candidate_v2","greenhouse_get_application","greenhouse_get_application_v2","greenhouse_get_candidate","greenhouse_get_candidate_v2","greenhouse_get_job","greenhouse_get_job_v2","greenhouse_get_user","greenhouse_get_user_v2","greenhouse_hire_application_v2","greenhouse_list_application_stages_v2","greenhouse_list_applications","greenhouse_list_applications_v2","greenhouse_list_applied_candidate_tags_v2","greenhouse_list_attachments_v2","greenhouse_list_candidate_educations_v2","greenhouse_list_candidate_employments_v2","greenhouse_list_candidate_tags_v2","greenhouse_list_candidates","greenhouse_list_candidates_v2","greenhouse_list_close_reasons_v2","greenhouse_list_custom_field_options_v2","greenhouse_list_custom_fields_v2","greenhouse_list_departments","greenhouse_list_departments_v2","greenhouse_list_email_templates_v2","greenhouse_list_interviews_v2","greenhouse_list_job_hiring_managers_v2","greenhouse_list_job_interview_stages_v2","greenhouse_list_job_owners_v2","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_jobs_v2","greenhouse_list_notes_v2","greenhouse_list_offers_v2","greenhouse_list_offices","greenhouse_list_offices_v2","greenhouse_list_openings_v2","greenhouse_list_prospect_pool_stages_v2","greenhouse_list_prospect_pools_v2","greenhouse_list_referrers_v2","greenhouse_list_rejection_reasons_v2","greenhouse_list_scorecards_v2","greenhouse_list_sources_v2","greenhouse_list_users","greenhouse_list_users_v2","greenhouse_move_application_v2","greenhouse_reject_application_v2","greenhouse_remove_candidate_tag_v2","greenhouse_unreject_application_v2","greenhouse_update_application_v2","greenhouse_update_candidate_v2","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","helpscout_assign_conversation","helpscout_create_conversation","helpscout_create_customer","helpscout_create_customer_thread","helpscout_create_note","helpscout_create_reply","helpscout_delete_conversation","helpscout_get_conversation","helpscout_get_current_user","helpscout_get_customer","helpscout_get_organization","helpscout_get_saved_reply","helpscout_list_conversations","helpscout_list_custom_fields","helpscout_list_customer_properties","helpscout_list_customers","helpscout_list_folders","helpscout_list_mailboxes","helpscout_list_organizations","helpscout_list_saved_replies","helpscout_list_tags","helpscout_list_teams","helpscout_list_threads","helpscout_list_users","helpscout_list_workflows","helpscout_move_conversation","helpscout_run_workflow","helpscout_set_conversation_tags","helpscout_snooze_conversation","helpscout_unassign_conversation","helpscout_unsnooze_conversation","helpscout_update_conversation_customer","helpscout_update_conversation_status","helpscout_update_conversation_subject","helpscout_update_custom_fields","helpscout_update_customer","helpscout_update_customer_properties","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","intune_get_compliance_policy","intune_get_detected_app","intune_get_device","intune_get_device_configuration","intune_list_app_devices","intune_list_compliance_policies","intune_list_compliance_policy_device_statuses","intune_list_detected_apps","intune_list_device_configurations","intune_list_devices","intune_reboot_device","intune_remote_lock_device","intune_retire_device","intune_sync_device","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_create_sprint","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_get_content_v2","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_download_file_v2","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_calculate","microsoft_excel_clear_range","microsoft_excel_close_session","microsoft_excel_create_session","microsoft_excel_create_table","microsoft_excel_delete_table","microsoft_excel_delete_table_row","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_get_table","microsoft_excel_get_table_row","microsoft_excel_get_worksheet","microsoft_excel_list_table_rows","microsoft_excel_list_tables","microsoft_excel_list_worksheets","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_refresh_session","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_update_table","microsoft_excel_update_table_row","microsoft_excel_update_worksheet","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","oracle_fusion_financials_apply_receivables_receipt","oracle_fusion_financials_approve_receivables_invoice","oracle_fusion_financials_create_expense_line","oracle_fusion_financials_create_expense_report","oracle_fusion_financials_create_receivables_credit_memo","oracle_fusion_financials_create_receivables_invoice","oracle_fusion_financials_create_receivables_receipt","oracle_fusion_financials_get_expense_line","oracle_fusion_financials_get_expense_report","oracle_fusion_financials_get_gl_journal_batch","oracle_fusion_financials_get_gl_ledger","oracle_fusion_financials_get_payables_invoice","oracle_fusion_financials_get_payables_invoice_line","oracle_fusion_financials_get_payables_payment","oracle_fusion_financials_get_receivables_credit_memo","oracle_fusion_financials_get_receivables_invoice","oracle_fusion_financials_get_receivables_invoice_installment","oracle_fusion_financials_get_receivables_invoice_line","oracle_fusion_financials_get_receivables_receipt","oracle_fusion_financials_list_expense_lines","oracle_fusion_financials_list_expense_reports","oracle_fusion_financials_list_gl_balances","oracle_fusion_financials_list_gl_journal_batches","oracle_fusion_financials_list_gl_ledgers","oracle_fusion_financials_list_payables_invoice_lines","oracle_fusion_financials_list_payables_invoices","oracle_fusion_financials_list_payables_payments","oracle_fusion_financials_list_receivables_credit_memos","oracle_fusion_financials_list_receivables_invoice_installments","oracle_fusion_financials_list_receivables_invoice_lines","oracle_fusion_financials_list_receivables_invoices","oracle_fusion_financials_list_receivables_receipts","oracle_fusion_financials_submit_expense_report","oracle_fusion_financials_update_expense_line","oracle_fusion_financials_update_expense_report","oracle_fusion_financials_update_receivables_invoice","oracledb_delete","oracledb_execute","oracledb_insert","oracledb_introspect","oracledb_query","oracledb_update","otter_create_conversation","otter_get_conversation","otter_get_conversation_audio","otter_get_workspace","otter_list_channel_members","otter_list_channels","otter_list_conversations","otter_list_workspace_conversations","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","plane_archive_project","plane_archive_work_item","plane_confirm_attachment_upload","plane_create_attachment_upload","plane_create_comment","plane_create_cycle","plane_create_label","plane_create_link","plane_create_module","plane_create_project","plane_create_project_page","plane_create_state","plane_create_webhook","plane_create_work_item","plane_delete_attachment","plane_delete_comment","plane_delete_cycle","plane_delete_label","plane_delete_link","plane_delete_module","plane_delete_project","plane_delete_project_page","plane_delete_state","plane_delete_webhook","plane_delete_work_item","plane_get_attachment","plane_get_comment","plane_get_current_user","plane_get_cycle","plane_get_label","plane_get_link","plane_get_module","plane_get_project","plane_get_project_page","plane_get_project_summary","plane_get_state","plane_get_webhook","plane_get_work_item","plane_get_work_item_by_identifier","plane_list_attachments","plane_list_comments","plane_list_cycles","plane_list_labels","plane_list_links","plane_list_modules","plane_list_project_pages","plane_list_projects","plane_list_states","plane_list_webhooks","plane_list_work_items","plane_list_workspace_work_items","plane_manage_cycle_work_items","plane_manage_module_work_items","plane_regenerate_webhook_secret","plane_transfer_cycle_work_items","plane_unarchive_project","plane_unarchive_work_item","plane_update_comment","plane_update_cycle","plane_update_label","plane_update_link","plane_update_module","plane_update_project","plane_update_project_page","plane_update_state","plane_update_webhook","plane_update_work_item","planetscale_close_deploy_request","planetscale_create_backup","planetscale_create_branch","planetscale_create_deploy_request","planetscale_delete_branch","planetscale_get_backup","planetscale_get_branch","planetscale_get_database","planetscale_get_deploy_request","planetscale_list_backups","planetscale_list_branches","planetscale_list_databases","planetscale_list_deploy_requests","planetscale_queue_deploy_request","planetscale_review_deploy_request","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","powerbi_execute_query","powerbi_get_dataset","powerbi_get_refresh_history","powerbi_get_report","powerbi_list_datasets","powerbi_list_reports","powerbi_list_workspaces","powerbi_refresh_dataset","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","ramp_create_memo","ramp_get_balance","ramp_get_bill","ramp_get_business","ramp_get_memo","ramp_get_physical_card","ramp_get_reimbursement","ramp_get_transaction","ramp_get_user","ramp_get_vendor","ramp_get_virtual_card","ramp_list_bills","ramp_list_physical_cards","ramp_list_reimbursements","ramp_list_transactions","ramp_list_users","ramp_list_vendors","ramp_list_virtual_cards","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_download_attachment_v2","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_download_v2","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_create_product_variants","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_fulfillment","shopify_get_fulfillment_order","shopify_get_inventory_level","shopify_get_job","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_fulfillment_orders","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_fulfillment_tracking","shopify_update_order","shopify_update_product","shopify_update_product_variants","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_bookmark","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_close_conversation","slack_create_channel_canvas","slack_create_conversation","slack_create_user_group","slack_delete_canvas","slack_delete_file","slack_delete_message","slack_delete_scheduled_message","slack_disable_user_group","slack_download","slack_edit_bookmark","slack_edit_canvas","slack_enable_user_group","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_dnd_info","slack_get_file_info","slack_get_message","slack_get_permalink","slack_get_reactions","slack_get_team_dnd_info","slack_get_team_info","slack_get_team_profile","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_get_user_profile","slack_invite_to_conversation","slack_join_conversation","slack_kick_conversation","slack_leave_conversation","slack_list_bookmarks","slack_list_canvases","slack_list_channels","slack_list_emoji","slack_list_files","slack_list_members","slack_list_pins","slack_list_reactions","slack_list_scheduled_messages","slack_list_user_conversations","slack_list_user_group_members","slack_list_user_groups","slack_list_users","slack_lists_access_delete","slack_lists_access_set","slack_lists_create","slack_lists_download_get","slack_lists_download_start","slack_lists_items_create","slack_lists_items_delete","slack_lists_items_delete_multiple","slack_lists_items_info","slack_lists_items_list","slack_lists_items_update","slack_lists_update","slack_lookup_canvas_sections","slack_lookup_user_by_email","slack_mark_conversation_read","slack_message","slack_message_reader","slack_open_conversation","slack_open_view","slack_pin_message","slack_publish_view","slack_push_view","slack_remove_bookmark","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_revoke_canvas_access","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_set_user_presence","slack_share_canvas","slack_unarchive_conversation","slack_unfurl_links","slack_unpin_message","slack_update_message","slack_update_user_group","slack_update_user_group_members","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_cortex_analyst_ask","snowflake_cortex_analyst_feedback","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_download_file_v2","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_approve_user","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_device_attribute","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_attributes","tailscale_get_device_routes","tailscale_get_dns_configuration","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_get_split_dns","tailscale_get_tailnet_settings","tailscale_get_user","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_restore_user","tailscale_set_acl","tailscale_set_device_attribute","tailscale_set_device_name","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_configuration","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_set_split_dns","tailscale_set_user_role","tailscale_suspend_user","tailscale_update_device_key","tailscale_update_split_dns","tailscale_update_tailnet_settings","tailscale_validate_acl","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youcom_answer","youcom_finance_research","youcom_get_account_balance","youcom_get_contents","youcom_get_research_task","youcom_research","youcom_search","youcom_search_images","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_create_account","zoho_desk_create_contact","zoho_desk_create_ticket","zoho_desk_delete_comment","zoho_desk_get_account","zoho_desk_get_agent","zoho_desk_get_attachment","zoho_desk_get_comment","zoho_desk_get_contact","zoho_desk_get_department","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_accounts","zoho_desk_list_agents","zoho_desk_list_comments","zoho_desk_list_contacts","zoho_desk_list_departments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_move_ticket","zoho_desk_search_accounts","zoho_desk_search_contacts","zoho_desk_search_tickets","zoho_desk_send_reply","zoho_desk_update_account","zoho_desk_update_comment","zoho_desk_update_contact","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' + '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_aggregated_post_metrics","buffer_get_channel","buffer_get_channels","buffer_get_daily_posting_limits","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","checkr_add_report_tag","checkr_apply_report_review_action","checkr_cancel_adverse_action","checkr_cancel_continuous_check","checkr_cancel_invitation","checkr_cancel_subscription","checkr_complete_report","checkr_create_adverse_action","checkr_create_candidate","checkr_create_continuous_check","checkr_create_geo","checkr_create_invitation","checkr_create_report","checkr_create_subscription","checkr_delete_candidate_pii","checkr_delete_geo","checkr_get_account","checkr_get_adverse_action","checkr_get_candidate","checkr_get_continuous_check","checkr_get_document","checkr_get_geo","checkr_get_invitation","checkr_get_node","checkr_get_package","checkr_get_program","checkr_get_report","checkr_get_report_eta","checkr_get_report_progressive_status","checkr_get_report_tags","checkr_get_screening","checkr_get_subscription","checkr_get_verification","checkr_list_adverse_actions","checkr_list_adverse_items","checkr_list_assessments","checkr_list_candidate_documents","checkr_list_candidates","checkr_list_continuous_checks","checkr_list_counties","checkr_list_geos","checkr_list_invitations","checkr_list_nodes","checkr_list_packages","checkr_list_programs","checkr_list_report_addresses","checkr_list_subscriptions","checkr_list_users","checkr_list_verifications","checkr_remove_report_tag","checkr_set_report_tags","checkr_update_candidate","checkr_update_continuous_check","checkr_update_geo","checkr_update_report","checkr_update_subscription","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_group","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_tunnel","cloudflare_create_worker_route","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_group","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_r2_bucket_cors","cloudflare_delete_ruleset","cloudflare_delete_ruleset_rule","cloudflare_delete_tunnel","cloudflare_delete_worker_route","cloudflare_delete_worker_script","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_access_group","cloudflare_get_access_policy","cloudflare_get_access_service_token","cloudflare_get_dns_record","cloudflare_get_r2_bucket","cloudflare_get_r2_bucket_cors","cloudflare_get_r2_bucket_lifecycle","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_tunnel_token","cloudflare_get_worker_route","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_refresh_access_service_token","cloudflare_revoke_access_service_token","cloudflare_rotate_access_service_token","cloudflare_update_access_application","cloudflare_update_access_group","cloudflare_update_access_policy","cloudflare_update_access_service_token","cloudflare_update_dns_record","cloudflare_update_r2_bucket_cors","cloudflare_update_r2_bucket_lifecycle","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset","cloudflare_update_ruleset_rule","cloudflare_update_tunnel","cloudflare_update_tunnel_configuration","cloudflare_update_worker_route","cloudflare_update_zone","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_all_runs","databricks_cancel_run","databricks_cancel_statement","databricks_create_cluster","databricks_create_job","databricks_create_warehouse","databricks_delete_cluster","databricks_delete_job","databricks_delete_run","databricks_delete_warehouse","databricks_edit_cluster","databricks_edit_warehouse","databricks_execute_sql","databricks_genie_agent_ask","databricks_genie_agent_cancel","databricks_genie_agent_list_items","databricks_genie_ask","databricks_genie_create_space","databricks_genie_delete_conversation","databricks_genie_delete_message","databricks_genie_delete_space","databricks_genie_download_visualization","databricks_genie_execute_query","databricks_genie_get_message","databricks_genie_get_query_result","databricks_genie_get_space","databricks_genie_list_conversations","databricks_genie_list_messages","databricks_genie_list_spaces","databricks_genie_send_feedback","databricks_genie_update_space","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_get_statement_chunk","databricks_get_warehouse","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_repair_run","databricks_reset_job","databricks_resize_cluster","databricks_restart_cluster","databricks_run_job","databricks_start_cluster","databricks_start_warehouse","databricks_stop_warehouse","databricks_submit_run","databricks_terminate_cluster","databricks_update_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_download_v2","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_get_qr_code_v2","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","ec2_allocate_address","ec2_associate_address","ec2_authorize_security_group_ingress","ec2_create_snapshot","ec2_create_tags","ec2_delete_snapshot","ec2_delete_tags","ec2_delete_volume","ec2_describe_addresses","ec2_describe_availability_zones","ec2_describe_images","ec2_describe_instance_status","ec2_describe_instances","ec2_describe_internet_gateways","ec2_describe_key_pairs","ec2_describe_nat_gateways","ec2_describe_network_acls","ec2_describe_network_interfaces","ec2_describe_regions","ec2_describe_route_tables","ec2_describe_security_groups","ec2_describe_snapshots","ec2_describe_subnets","ec2_describe_volumes","ec2_describe_vpc_endpoints","ec2_describe_vpc_peering_connections","ec2_describe_vpcs","ec2_disassociate_address","ec2_reboot_instances","ec2_release_address","ec2_revoke_security_group_ingress","ec2_start_instances","ec2_stop_instances","ec2_terminate_instances","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_enrich","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","fivetran_cancel_transformation","fivetran_create_connect_card","fivetran_create_connection","fivetran_create_destination","fivetran_create_group","fivetran_delete_connection","fivetran_get_account_info","fivetran_get_connection","fivetran_get_connection_schema_config","fivetran_get_connection_warnings","fivetran_get_connector_type","fivetran_get_destination","fivetran_get_group","fivetran_get_sync_history","fivetran_get_transformation","fivetran_list_connections","fivetran_list_connector_types","fivetran_list_destinations","fivetran_list_groups","fivetran_list_transformations","fivetran_list_users","fivetran_reload_connection_schema","fivetran_resync_connection","fivetran_resync_tables","fivetran_run_connection_setup_tests","fivetran_run_destination_setup_tests","fivetran_run_transformation","fivetran_sync_connection","fivetran_update_connection","fivetran_update_connection_schema","fivetran_update_connection_table","fivetran_update_schema_config","flint_create_task","flint_generate_pages","flint_get_task","flow_add_link","flow_create_branch","flow_create_comment","flow_create_entities","flow_create_entity","flow_delete_comment","flow_delete_entity","flow_filter_entities","flow_get_comment","flow_get_data_model","flow_get_entities","flow_get_entity_history","flow_get_project","flow_list_branches","flow_list_comments","flow_list_entities","flow_list_projects","flow_remove_link","flow_restore_entity","flow_search_entities","flow_set_value","flow_set_values","flow_update_comment","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","gauge_add_tags_to_prompts","gauge_bulk_update_prompts","gauge_create_prompts","gauge_create_tag","gauge_create_topic","gauge_delete_tag","gauge_get_answer","gauge_get_category","gauge_get_current_credential","gauge_get_organization","gauge_get_prompt","gauge_get_query_metadata","gauge_get_tag","gauge_get_topic","gauge_list_answers","gauge_list_brands","gauge_list_categories","gauge_list_citations","gauge_list_organizations","gauge_list_prompts","gauge_list_tags","gauge_list_topics","gauge_query","gauge_remove_tags_from_prompts","gauge_rename_topic","gauge_update_prompt","gauge_update_tag","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_respond","google_calendar_respond_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_apply_candidate_tag_v2","greenhouse_convert_prospect_v2","greenhouse_create_application_v2","greenhouse_create_attachment_v2","greenhouse_create_candidate_tag_v2","greenhouse_create_candidate_v2","greenhouse_create_note_v2","greenhouse_delete_application_v2","greenhouse_delete_attachment_v2","greenhouse_delete_candidate_v2","greenhouse_get_application","greenhouse_get_application_v2","greenhouse_get_candidate","greenhouse_get_candidate_v2","greenhouse_get_job","greenhouse_get_job_v2","greenhouse_get_user","greenhouse_get_user_v2","greenhouse_hire_application_v2","greenhouse_list_application_stages_v2","greenhouse_list_applications","greenhouse_list_applications_v2","greenhouse_list_applied_candidate_tags_v2","greenhouse_list_attachments_v2","greenhouse_list_candidate_educations_v2","greenhouse_list_candidate_employments_v2","greenhouse_list_candidate_tags_v2","greenhouse_list_candidates","greenhouse_list_candidates_v2","greenhouse_list_close_reasons_v2","greenhouse_list_custom_field_options_v2","greenhouse_list_custom_fields_v2","greenhouse_list_departments","greenhouse_list_departments_v2","greenhouse_list_email_templates_v2","greenhouse_list_interviews_v2","greenhouse_list_job_hiring_managers_v2","greenhouse_list_job_interview_stages_v2","greenhouse_list_job_owners_v2","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_jobs_v2","greenhouse_list_notes_v2","greenhouse_list_offers_v2","greenhouse_list_offices","greenhouse_list_offices_v2","greenhouse_list_openings_v2","greenhouse_list_prospect_pool_stages_v2","greenhouse_list_prospect_pools_v2","greenhouse_list_referrers_v2","greenhouse_list_rejection_reasons_v2","greenhouse_list_scorecards_v2","greenhouse_list_sources_v2","greenhouse_list_users","greenhouse_list_users_v2","greenhouse_move_application_v2","greenhouse_reject_application_v2","greenhouse_remove_candidate_tag_v2","greenhouse_unreject_application_v2","greenhouse_update_application_v2","greenhouse_update_candidate_v2","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","helpscout_assign_conversation","helpscout_create_conversation","helpscout_create_customer","helpscout_create_customer_thread","helpscout_create_note","helpscout_create_reply","helpscout_delete_conversation","helpscout_get_conversation","helpscout_get_current_user","helpscout_get_customer","helpscout_get_organization","helpscout_get_saved_reply","helpscout_list_conversations","helpscout_list_custom_fields","helpscout_list_customer_properties","helpscout_list_customers","helpscout_list_folders","helpscout_list_mailboxes","helpscout_list_organizations","helpscout_list_saved_replies","helpscout_list_tags","helpscout_list_teams","helpscout_list_threads","helpscout_list_users","helpscout_list_workflows","helpscout_move_conversation","helpscout_run_workflow","helpscout_set_conversation_tags","helpscout_snooze_conversation","helpscout_unassign_conversation","helpscout_unsnooze_conversation","helpscout_update_conversation_customer","helpscout_update_conversation_status","helpscout_update_conversation_subject","helpscout_update_custom_fields","helpscout_update_customer","helpscout_update_customer_properties","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","intune_get_compliance_policy","intune_get_detected_app","intune_get_device","intune_get_device_configuration","intune_list_app_devices","intune_list_compliance_policies","intune_list_compliance_policy_device_statuses","intune_list_detected_apps","intune_list_device_configurations","intune_list_devices","intune_reboot_device","intune_remote_lock_device","intune_retire_device","intune_sync_device","jama_create_comment","jama_create_item","jama_create_relationship","jama_delete_item","jama_delete_relationship","jama_get_baseline","jama_get_current_user","jama_get_filter","jama_get_item","jama_get_item_type","jama_get_item_version","jama_get_project","jama_get_relationship","jama_get_test_cycle","jama_get_test_plan","jama_get_test_run","jama_list_baseline_items","jama_list_baselines","jama_list_downstream_items","jama_list_filter_results","jama_list_filters","jama_list_item_children","jama_list_item_comments","jama_list_item_types","jama_list_item_versions","jama_list_items","jama_list_pick_list_options","jama_list_pick_lists","jama_list_projects","jama_list_relationship_types","jama_list_relationships","jama_list_releases","jama_list_tags","jama_list_test_cycles","jama_list_test_plans","jama_list_test_runs","jama_list_upstream_items","jama_search_items","jama_update_item","jama_update_test_run","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_create_sprint","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_get_content_v2","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_download_file_v2","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_calculate","microsoft_excel_clear_range","microsoft_excel_close_session","microsoft_excel_create_session","microsoft_excel_create_table","microsoft_excel_delete_table","microsoft_excel_delete_table_row","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_get_table","microsoft_excel_get_table_row","microsoft_excel_get_worksheet","microsoft_excel_list_table_rows","microsoft_excel_list_tables","microsoft_excel_list_worksheets","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_refresh_session","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_update_table","microsoft_excel_update_table_row","microsoft_excel_update_worksheet","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","oracle_fusion_financials_apply_receivables_receipt","oracle_fusion_financials_approve_receivables_invoice","oracle_fusion_financials_create_expense_line","oracle_fusion_financials_create_expense_report","oracle_fusion_financials_create_receivables_credit_memo","oracle_fusion_financials_create_receivables_invoice","oracle_fusion_financials_create_receivables_receipt","oracle_fusion_financials_get_expense_line","oracle_fusion_financials_get_expense_report","oracle_fusion_financials_get_gl_journal_batch","oracle_fusion_financials_get_gl_ledger","oracle_fusion_financials_get_payables_invoice","oracle_fusion_financials_get_payables_invoice_line","oracle_fusion_financials_get_payables_payment","oracle_fusion_financials_get_receivables_credit_memo","oracle_fusion_financials_get_receivables_invoice","oracle_fusion_financials_get_receivables_invoice_installment","oracle_fusion_financials_get_receivables_invoice_line","oracle_fusion_financials_get_receivables_receipt","oracle_fusion_financials_list_expense_lines","oracle_fusion_financials_list_expense_reports","oracle_fusion_financials_list_gl_balances","oracle_fusion_financials_list_gl_journal_batches","oracle_fusion_financials_list_gl_ledgers","oracle_fusion_financials_list_payables_invoice_lines","oracle_fusion_financials_list_payables_invoices","oracle_fusion_financials_list_payables_payments","oracle_fusion_financials_list_receivables_credit_memos","oracle_fusion_financials_list_receivables_invoice_installments","oracle_fusion_financials_list_receivables_invoice_lines","oracle_fusion_financials_list_receivables_invoices","oracle_fusion_financials_list_receivables_receipts","oracle_fusion_financials_submit_expense_report","oracle_fusion_financials_update_expense_line","oracle_fusion_financials_update_expense_report","oracle_fusion_financials_update_receivables_invoice","oracledb_delete","oracledb_execute","oracledb_insert","oracledb_introspect","oracledb_query","oracledb_update","otter_create_conversation","otter_get_conversation","otter_get_conversation_audio","otter_get_workspace","otter_list_channel_members","otter_list_channels","otter_list_conversations","otter_list_workspace_conversations","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","plane_archive_project","plane_archive_work_item","plane_confirm_attachment_upload","plane_create_attachment_upload","plane_create_comment","plane_create_cycle","plane_create_label","plane_create_link","plane_create_module","plane_create_project","plane_create_project_page","plane_create_state","plane_create_webhook","plane_create_work_item","plane_delete_attachment","plane_delete_comment","plane_delete_cycle","plane_delete_label","plane_delete_link","plane_delete_module","plane_delete_project","plane_delete_project_page","plane_delete_state","plane_delete_webhook","plane_delete_work_item","plane_get_attachment","plane_get_comment","plane_get_current_user","plane_get_cycle","plane_get_label","plane_get_link","plane_get_module","plane_get_project","plane_get_project_page","plane_get_project_summary","plane_get_state","plane_get_webhook","plane_get_work_item","plane_get_work_item_by_identifier","plane_list_attachments","plane_list_comments","plane_list_cycles","plane_list_labels","plane_list_links","plane_list_modules","plane_list_project_pages","plane_list_projects","plane_list_states","plane_list_webhooks","plane_list_work_items","plane_list_workspace_work_items","plane_manage_cycle_work_items","plane_manage_module_work_items","plane_regenerate_webhook_secret","plane_transfer_cycle_work_items","plane_unarchive_project","plane_unarchive_work_item","plane_update_comment","plane_update_cycle","plane_update_label","plane_update_link","plane_update_module","plane_update_project","plane_update_project_page","plane_update_state","plane_update_webhook","plane_update_work_item","planetscale_close_deploy_request","planetscale_create_backup","planetscale_create_branch","planetscale_create_deploy_request","planetscale_delete_branch","planetscale_get_backup","planetscale_get_branch","planetscale_get_database","planetscale_get_deploy_request","planetscale_list_backups","planetscale_list_branches","planetscale_list_databases","planetscale_list_deploy_requests","planetscale_queue_deploy_request","planetscale_review_deploy_request","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","powerbi_execute_query","powerbi_get_dataset","powerbi_get_refresh_history","powerbi_get_report","powerbi_list_datasets","powerbi_list_reports","powerbi_list_workspaces","powerbi_refresh_dataset","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","ramp_create_memo","ramp_get_balance","ramp_get_bill","ramp_get_business","ramp_get_memo","ramp_get_physical_card","ramp_get_reimbursement","ramp_get_transaction","ramp_get_user","ramp_get_vendor","ramp_get_virtual_card","ramp_list_bills","ramp_list_physical_cards","ramp_list_reimbursements","ramp_list_transactions","ramp_list_users","ramp_list_vendors","ramp_list_virtual_cards","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_download_attachment_v2","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_download_v2","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_create_product_variants","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_fulfillment","shopify_get_fulfillment_order","shopify_get_inventory_level","shopify_get_job","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_fulfillment_orders","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_fulfillment_tracking","shopify_update_order","shopify_update_product","shopify_update_product_variants","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_bookmark","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_close_conversation","slack_create_channel_canvas","slack_create_conversation","slack_create_user_group","slack_delete_canvas","slack_delete_file","slack_delete_message","slack_delete_scheduled_message","slack_disable_user_group","slack_download","slack_edit_bookmark","slack_edit_canvas","slack_enable_user_group","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_dnd_info","slack_get_file_info","slack_get_message","slack_get_permalink","slack_get_reactions","slack_get_team_dnd_info","slack_get_team_info","slack_get_team_profile","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_get_user_profile","slack_invite_to_conversation","slack_join_conversation","slack_kick_conversation","slack_leave_conversation","slack_list_bookmarks","slack_list_canvases","slack_list_channels","slack_list_emoji","slack_list_files","slack_list_members","slack_list_pins","slack_list_reactions","slack_list_scheduled_messages","slack_list_user_conversations","slack_list_user_group_members","slack_list_user_groups","slack_list_users","slack_lists_access_delete","slack_lists_access_set","slack_lists_create","slack_lists_download_get","slack_lists_download_start","slack_lists_items_create","slack_lists_items_delete","slack_lists_items_delete_multiple","slack_lists_items_info","slack_lists_items_list","slack_lists_items_update","slack_lists_update","slack_lookup_canvas_sections","slack_lookup_user_by_email","slack_mark_conversation_read","slack_message","slack_message_reader","slack_open_conversation","slack_open_view","slack_pin_message","slack_publish_view","slack_push_view","slack_remove_bookmark","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_revoke_canvas_access","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_set_user_presence","slack_share_canvas","slack_unarchive_conversation","slack_unfurl_links","slack_unpin_message","slack_update_message","slack_update_user_group","slack_update_user_group_members","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_cortex_analyst_ask","snowflake_cortex_analyst_feedback","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_download_file_v2","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_approve_user","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_device_attribute","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_attributes","tailscale_get_device_routes","tailscale_get_dns_configuration","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_get_split_dns","tailscale_get_tailnet_settings","tailscale_get_user","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_restore_user","tailscale_set_acl","tailscale_set_device_attribute","tailscale_set_device_name","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_configuration","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_set_split_dns","tailscale_set_user_role","tailscale_suspend_user","tailscale_update_device_key","tailscale_update_split_dns","tailscale_update_tailnet_settings","tailscale_validate_acl","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youcom_answer","youcom_finance_research","youcom_get_account_balance","youcom_get_contents","youcom_get_research_task","youcom_research","youcom_search","youcom_search_images","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_create_account","zoho_desk_create_contact","zoho_desk_create_ticket","zoho_desk_delete_comment","zoho_desk_get_account","zoho_desk_get_agent","zoho_desk_get_attachment","zoho_desk_get_comment","zoho_desk_get_contact","zoho_desk_get_department","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_accounts","zoho_desk_list_agents","zoho_desk_list_comments","zoho_desk_list_contacts","zoho_desk_list_departments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_move_ticket","zoho_desk_search_accounts","zoho_desk_search_contacts","zoho_desk_search_tickets","zoho_desk_send_reply","zoho_desk_update_account","zoho_desk_update_comment","zoho_desk_update_contact","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index c4e808ead5f..c9a0ff50077 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"hostedApiKey":"none"},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"hostedApiKey":"none"},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}},"hostedApiKey":"none"},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}},"hostedApiKey":"none"},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}},"hostedApiKey":"none"},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}},"hostedApiKey":"none"},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"hostedApiKey":"none"},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}},"hostedApiKey":"none"},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}},"hostedApiKey":"none"},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}},"hostedApiKey":"none"},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}},"hostedApiKey":"none"},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}},"hostedApiKey":"none"},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}},"hostedApiKey":"none"},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"hostedApiKey":"none"},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"hostedApiKey":"none"},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}},"hostedApiKey":"none"},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}},"hostedApiKey":"none"},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}},"hostedApiKey":"none"},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}},"hostedApiKey":"none"},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}},"hostedApiKey":"none"},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}},"hostedApiKey":"none"},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"hostedApiKey":"none"},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}},"hostedApiKey":"none"},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}},"hostedApiKey":"none"},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"hostedApiKey":"none"},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}},"hostedApiKey":"none"},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}},"hostedApiKey":"none"},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}},"hostedApiKey":"none"},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}},"hostedApiKey":"none"},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}},"hostedApiKey":"none"},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}},"hostedApiKey":"none"},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}},"hostedApiKey":"none"},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}},"hostedApiKey":"none"},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}},"hostedApiKey":"none"},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}},"hostedApiKey":"none"},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}},"hostedApiKey":"none"},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}},"hostedApiKey":"none"},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}},"hostedApiKey":"none"},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}},"hostedApiKey":"none"},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}},"hostedApiKey":"none"},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}},"hostedApiKey":"none"},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}},"hostedApiKey":"none"},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}},"hostedApiKey":"none"},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}},"hostedApiKey":"none"},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}},"hostedApiKey":"none"},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}},"hostedApiKey":"none"},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}},"hostedApiKey":"none"},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}},"hostedApiKey":"none"},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}},"hostedApiKey":"none"},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}},"hostedApiKey":"none"},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}},"hostedApiKey":"none"},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}},"hostedApiKey":"none"},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}},"hostedApiKey":"none"},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}},"hostedApiKey":"none"},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}},"hostedApiKey":"none"},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"hostedApiKey":"none"},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}},"hostedApiKey":"none"},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}},"hostedApiKey":"none"},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}},"hostedApiKey":"none"},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}},"hostedApiKey":"none"},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}},"hostedApiKey":"none"},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}},"hostedApiKey":"none"},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}},"hostedApiKey":"none"},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}},"hostedApiKey":"none"},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}},"hostedApiKey":"none"},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}},"hostedApiKey":"none"},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}},"hostedApiKey":"none"},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"hostedApiKey":"none"},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}},"hostedApiKey":"none"},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}},"hostedApiKey":"none"},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}},"hostedApiKey":"none"},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}},"hostedApiKey":"none"},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Start an APIFY Actor run and poll it within the same workflow step until completion or the polling deadline","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"hostedApiKey":"none"},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"hostedApiKey":"none"},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ] }"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}},"hostedApiKey":"none"},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}},"hostedApiKey":"none"},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}},"hostedApiKey":"none"},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}},"hostedApiKey":"none"},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}},"hostedApiKey":"none"},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}},"hostedApiKey":"none"},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}},"hostedApiKey":"none"},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}},"hostedApiKey":"none"},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}},"hostedApiKey":"none"},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}},"hostedApiKey":"none"},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}},"hostedApiKey":"none"},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"hostedApiKey":"none"},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"hostedApiKey":"none"},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}},"hostedApiKey":"none"},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}},"hostedApiKey":"none"},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}},"hostedApiKey":"none"},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}},"hostedApiKey":"none"},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}},"hostedApiKey":"none"},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}},"hostedApiKey":"none"},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}},"hostedApiKey":"none"},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}},"hostedApiKey":"none"},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}},"hostedApiKey":"none"},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}},"hostedApiKey":"none"},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}},"hostedApiKey":"none"},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}},"hostedApiKey":"none"},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}},"hostedApiKey":"none"},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}},"hostedApiKey":"none"},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}},"hostedApiKey":"none"},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}},"hostedApiKey":"none"},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}},"hostedApiKey":"none"},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}},"hostedApiKey":"none"},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}},"hostedApiKey":"none"},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Remove personally identifiable information from an Ashby candidate while retaining the candidate record and its applications. This operation does not delete the candidate. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"hostedApiKey":"none"},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}},"hostedApiKey":"none"},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"hostedApiKey":"none"},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in, or FirstPreInterviewScreen (defaults to the first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"hostedApiKey":"none"},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"hostedApiKey":"none"},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,